diff options
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/App.tsx | 308 | ||||
| -rw-r--r-- | frontend/src/components/ContextMenu.tsx | 22 | ||||
| -rw-r--r-- | frontend/src/components/LeftSidebar.tsx | 750 | ||||
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 2116 | ||||
| -rw-r--r-- | frontend/src/components/edges/MergedEdge.tsx | 77 | ||||
| -rw-r--r-- | frontend/src/components/nodes/LLMNode.tsx | 264 | ||||
| -rw-r--r-- | frontend/src/index.css | 182 | ||||
| -rw-r--r-- | frontend/src/store/flowStore.ts | 2041 |
8 files changed, 5498 insertions, 262 deletions
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8c52751..8ae93c7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import ReactFlow, { Background, Controls, @@ -6,20 +6,27 @@ import ReactFlow, { ReactFlowProvider, Panel, useReactFlow, + SelectionMode, type Node, type Edge } from 'reactflow'; import 'reactflow/dist/style.css'; import useFlowStore from './store/flowStore'; import LLMNode from './components/nodes/LLMNode'; +import MergedEdge from './components/edges/MergedEdge'; import Sidebar from './components/Sidebar'; +import LeftSidebar from './components/LeftSidebar'; import { ContextMenu } from './components/ContextMenu'; -import { Plus } from 'lucide-react'; +import { Plus, Sun, Moon, LayoutGrid } from 'lucide-react'; const nodeTypes = { llmNode: LLMNode, }; +const edgeTypes = { + merged: MergedEdge, +}; + function Flow() { const { nodes, @@ -31,17 +38,48 @@ function Flow() { deleteEdge, deleteNode, deleteBranch, - setSelectedNode + deleteTrace, + setSelectedNode, + toggleNodeDisabled, + archiveNode, + createNodeFromArchive, + toggleTraceDisabled, + theme, + toggleTheme, + autoLayout, + findNonOverlappingPosition, + setLastViewport, + saveCurrentBlueprint, + currentBlueprintPath } = useFlowStore(); const reactFlowWrapper = useRef<HTMLDivElement>(null); - const { project } = useReactFlow(); - const [menu, setMenu] = useState<{ x: number; y: number; type: 'pane' | 'node' | 'edge'; id?: string } | null>(null); + const { project, getViewport } = useReactFlow(); + const [menu, setMenu] = useState<{ x: number; y: number; type: 'pane' | 'node' | 'edge' | 'multiselect'; id?: string; selectedIds?: string[] } | null>(null); + + const [isLeftOpen, setIsLeftOpen] = useState(true); + const [isRightOpen, setIsRightOpen] = useState(true); + + // Get selected nodes + const selectedNodes = nodes.filter(n => n.selected); - const onPaneClick = () => { + const onPaneClick = useCallback(() => { setSelectedNode(null); setMenu(null); - }; + }, [setSelectedNode]); + + // Close menu on various interactions + const closeMenu = useCallback(() => { + setMenu(null); + }, []); + + const handleNodeDragStart = useCallback(() => { + setMenu(null); + }, []); + + const handleMoveStart = useCallback(() => { + setMenu(null); + }, []); const handlePaneContextMenu = (event: React.MouseEvent) => { event.preventDefault(); @@ -50,7 +88,33 @@ function Flow() { const handleNodeContextMenu = (event: React.MouseEvent, node: Node) => { event.preventDefault(); - setMenu({ x: event.clientX, y: event.clientY, type: 'node', id: node.id }); + // Check if multiple nodes are selected and the right-clicked node is one of them + if (selectedNodes.length > 1 && selectedNodes.some(n => n.id === node.id)) { + setMenu({ + x: event.clientX, + y: event.clientY, + type: 'multiselect', + selectedIds: selectedNodes.map(n => n.id) + }); + } else { + setMenu({ x: event.clientX, y: event.clientY, type: 'node', id: node.id }); + } + }; + + // Batch operations for multi-select + const handleBatchDelete = (nodeIds: string[]) => { + nodeIds.forEach(id => deleteNode(id)); + setMenu(null); + }; + + const handleBatchDisable = (nodeIds: string[]) => { + nodeIds.forEach(id => toggleNodeDisabled(id)); + setMenu(null); + }; + + const handleBatchArchive = (nodeIds: string[]) => { + nodeIds.forEach(id => archiveNode(id)); + setMenu(null); }; const handleEdgeContextMenu = (event: React.MouseEvent, edge: Edge) => { @@ -60,7 +124,20 @@ function Flow() { const handleAddNode = (position?: { x: number, y: number }) => { const id = `node_${Date.now()}`; - const pos = position || { x: Math.random() * 400, y: Math.random() * 400 }; + + // If no position provided, use viewport center + let basePos = position; + if (!basePos && reactFlowWrapper.current) { + const { x, y, zoom } = getViewport(); + const rect = reactFlowWrapper.current.getBoundingClientRect(); + // Calculate center of viewport in flow coordinates + basePos = { + x: (-x + rect.width / 2) / zoom - 100, // offset by half node width + y: (-y + rect.height / 2) / zoom - 40 // offset by half node height + }; + } + basePos = basePos || { x: 200, y: 200 }; + const pos = findNonOverlappingPosition(basePos.x, basePos.y); addNode({ id, @@ -68,15 +145,17 @@ function Flow() { position: pos, data: { label: 'New Question', - model: 'gpt-4o', + model: 'gpt-5.1', temperature: 0.7, systemPrompt: '', userPrompt: '', mergeStrategy: 'smart', + reasoningEffort: 'medium', // Default for reasoning models messages: [], traces: [], outgoingTraces: [], forkedTraces: [], + mergedTraces: [], response: '', status: 'idle', inputs: 1 @@ -86,36 +165,126 @@ function Flow() { }; const onNodeClick = (_: any, node: Node) => { + // Don't select disabled nodes + const nodeData = node.data as any; + if (nodeData?.disabled) return; setSelectedNode(node.id); }; + const onDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }; + + const onDrop = (event: React.DragEvent) => { + event.preventDefault(); + + const archiveId = event.dataTransfer.getData('archiveId'); + if (!archiveId) return; + + const bounds = reactFlowWrapper.current?.getBoundingClientRect(); + if (!bounds) return; + + const position = project({ + x: event.clientX - bounds.left, + y: event.clientY - bounds.top + }); + + createNodeFromArchive(archiveId, position); + }; + + // Ctrl/Cmd + S manual save + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const isMac = navigator.platform.toLowerCase().includes('mac'); + if ((isMac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === 's') { + e.preventDefault(); + const path = currentBlueprintPath; + if (path) { + saveCurrentBlueprint(path, getViewport()); + } + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [currentBlueprintPath, saveCurrentBlueprint, getViewport]); + return ( - <div style={{ width: '100vw', height: '100vh', display: 'flex' }}> - <div style={{ flex: 1, height: '100%' }} ref={reactFlowWrapper}> + <div className={`w-screen h-screen flex ${theme === 'dark' ? 'dark bg-gray-900' : 'bg-white'}`}> + <LeftSidebar isOpen={isLeftOpen} onToggle={() => setIsLeftOpen(!isLeftOpen)} /> + + <div + className={`flex-1 h-full relative ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`} + ref={reactFlowWrapper} + onDragOver={onDragOver} + onDrop={onDrop} + > <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} + onMoveEnd={(_, viewport) => setLastViewport(viewport)} nodeTypes={nodeTypes} - onNodeClick={onNodeClick} + edgeTypes={edgeTypes} + defaultEdgeOptions={{ type: 'merged' }} + onNodeClick={(e, node) => { closeMenu(); onNodeClick(e, node); }} onPaneClick={onPaneClick} onPaneContextMenu={handlePaneContextMenu} onNodeContextMenu={handleNodeContextMenu} onEdgeContextMenu={handleEdgeContextMenu} + onNodeDragStart={handleNodeDragStart} + onMoveStart={handleMoveStart} + onSelectionStart={closeMenu} fitView + panOnDrag + selectionOnDrag + selectionKeyCode="Shift" + multiSelectionKeyCode="Shift" + selectionMode={SelectionMode.Partial} > - <Background color="#aaa" gap={16} /> - <Controls /> - <MiniMap /> + <Background color={theme === 'dark' ? '#374151' : '#aaa'} gap={16} /> + <Controls className={theme === 'dark' ? 'dark-controls' : ''} /> + <MiniMap + nodeColor={theme === 'dark' ? '#4b5563' : '#e5e7eb'} + maskColor={theme === 'dark' ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.6)'} + /> <Panel position="top-left"> - <button - onClick={() => handleAddNode()} - className="bg-white px-4 py-2 rounded-md shadow-md font-medium text-gray-700 hover:bg-gray-50 flex items-center gap-2" - > - <Plus size={16} /> Add Block - </button> + <div className="flex gap-2"> + <button + onClick={() => handleAddNode()} + className={`px-4 py-2 rounded-md shadow-md font-medium flex items-center gap-2 transition-colors ${ + theme === 'dark' + ? 'bg-gray-800 text-gray-200 hover:bg-gray-700 border border-gray-600' + : 'bg-white text-gray-700 hover:bg-gray-50' + }`} + > + <Plus size={16} /> Add Node + </button> + <button + onClick={autoLayout} + className={`px-3 py-2 rounded-md shadow-md font-medium flex items-center gap-2 transition-colors ${ + theme === 'dark' + ? 'bg-gray-800 text-gray-200 hover:bg-gray-700 border border-gray-600' + : 'bg-white text-gray-700 hover:bg-gray-50' + }`} + title="Auto Layout" + > + <LayoutGrid size={16} /> + </button> + <button + onClick={toggleTheme} + className={`px-3 py-2 rounded-md shadow-md font-medium flex items-center gap-2 transition-colors ${ + theme === 'dark' + ? 'bg-gray-800 text-gray-200 hover:bg-gray-700 border border-gray-600' + : 'bg-white text-gray-700 hover:bg-gray-50' + }`} + title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'} + > + {theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />} + </button> + </div> </Panel> </ReactFlow> @@ -139,28 +308,89 @@ function Flow() { } } } - ] : menu.type === 'node' ? [ - { - label: 'Delete Node (Cascade)', - danger: true, - onClick: () => menu.id && deleteBranch(menu.id) - } - ] : [ - { - label: 'Disconnect', - onClick: () => menu.id && deleteEdge(menu.id) - }, - { - label: 'Delete Branch', - danger: true, - onClick: () => menu.id && deleteBranch(undefined, menu.id) + ] : menu.type === 'multiselect' ? (() => { + // Multi-select menu - batch operations + const ids = menu.selectedIds || []; + const allDisabled = ids.every(id => nodes.find(n => n.id === id)?.data?.disabled); + + return [ + { + label: allDisabled ? `Enable ${ids.length} Nodes` : `Disable ${ids.length} Nodes`, + onClick: () => handleBatchDisable(ids) + }, + { + label: `Archive ${ids.length} Nodes`, + onClick: () => handleBatchArchive(ids) + }, + { + label: `Delete ${ids.length} Nodes`, + danger: true, + onClick: () => handleBatchDelete(ids) + } + ]; + })() : menu.type === 'node' ? (() => { + const targetNode = nodes.find(n => n.id === menu.id); + const isDisabled = targetNode?.data?.disabled; + + // If disabled, only show Enable option + if (isDisabled) { + return [ + { + label: 'Enable Node', + onClick: () => menu.id && toggleNodeDisabled(menu.id) + } + ]; } - ] + + // Normal node menu + return [ + { + label: 'Disable Node', + onClick: () => menu.id && toggleNodeDisabled(menu.id) + }, + { + label: 'Add to Archive', + onClick: () => menu.id && archiveNode(menu.id) + }, + { + label: 'Delete Node (Cascade)', + danger: true, + onClick: () => menu.id && deleteBranch(menu.id) + } + ]; + })() : menu.type === 'edge' ? (() => { + // Check if any node connected to this edge is disabled + const targetEdge = edges.find(e => e.id === menu.id); + const sourceNode = nodes.find(n => n.id === targetEdge?.source); + const targetNode = nodes.find(n => n.id === targetEdge?.target); + const isTraceDisabled = sourceNode?.data?.disabled || targetNode?.data?.disabled; + + return [ + { + label: isTraceDisabled ? 'Enable Trace' : 'Disable Trace', + onClick: () => menu.id && toggleTraceDisabled(menu.id) + }, + { + label: 'Disconnect', + onClick: () => menu.id && deleteEdge(menu.id) + }, + { + label: 'Delete Branch', + danger: true, + onClick: () => menu.id && deleteBranch(undefined, menu.id) + }, + { + label: 'Delete Trace', + danger: true, + onClick: () => menu.id && deleteTrace(menu.id) + } + ]; + })() : [] } /> )} </div> - <Sidebar /> + <Sidebar isOpen={isRightOpen} onToggle={() => setIsRightOpen(!isRightOpen)} onInteract={closeMenu} /> </div> ); } 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 new file mode 100644 index 0000000..a75df39 --- /dev/null +++ b/frontend/src/components/LeftSidebar.tsx @@ -0,0 +1,750 @@ +import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { useReactFlow } from 'reactflow'; +import { + Folder, FileText, Archive, ChevronLeft, ChevronRight, Trash2, MessageSquare, + MoreVertical, Download, Upload, Plus, RefreshCw, Edit3 +} from 'lucide-react'; +import useFlowStore, { type FSItem, type BlueprintDocument, type FileMeta } from '../store/flowStore'; + +interface LeftSidebarProps { + isOpen: boolean; + onToggle: () => void; +} + +const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => { + const [activeTab, setActiveTab] = useState<'project' | 'files' | 'archive'>('project'); + const { + archivedNodes, + removeFromArchive, + createNodeFromArchive, + theme, + files, + projectTree, + currentBlueprintPath, + saveStatus, + refreshProjectTree, + loadArchivedNodes, + refreshFiles, + uploadFile, + deleteFile, + readBlueprintFile, + loadBlueprint, + saveBlueprintFile, + saveCurrentBlueprint, + createProjectFolder, + renameProjectItem, + deleteProjectItem, + setCurrentBlueprintPath, + serializeBlueprint, + clearBlueprint + } = useFlowStore(); + const { setViewport, getViewport } = useReactFlow(); + const isDark = theme === 'dark'; + const fileInputRef = useRef<HTMLInputElement | null>(null); + const fileUploadRef = useRef<HTMLInputElement | null>(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item?: FSItem } | null>(null); + const [currentFolder, setCurrentFolder] = useState<string>('.'); + const [dragItem, setDragItem] = useState<FSItem | null>(null); + const [showSaveStatus, setShowSaveStatus] = useState(false); + const [expanded, setExpanded] = useState<Set<string>>(() => new Set(['.'])); + + const handleDragStart = (e: React.DragEvent, archiveId: string) => { + e.dataTransfer.setData('archiveId', archiveId); + e.dataTransfer.effectAllowed = 'copy'; + }; + + const joinPath = (folder: string, name: string) => { + if (!folder || folder === '.' || folder === '/') return name; + return `${folder.replace(/\\/g, '/').replace(/\/+$/, '')}/${name}`; + }; + + const stripJson = (name: string) => name.endsWith('.json') ? name.slice(0, -5) : name; + + const findChildren = useCallback((folder: string, list: FSItem[] = projectTree): FSItem[] => { + const norm = folder.replace(/\\/g, '/').replace(/^\.\/?/, ''); + if (folder === '.' || folder === '' || folder === '/') return list; + for (const item of list) { + if (item.type === 'folder') { + if (item.path === norm) return item.children || []; + const found = findChildren(folder, item.children || []); + if (found) return found; + } + } + return []; + }, [projectTree]); + + const ensureUniqueName = useCallback((base: string, targetFolder: string, isFolder: boolean) => { + const siblings = findChildren(targetFolder).map(i => i.name); + const ext = isFolder ? '' : '.json'; + const rawBase = stripJson(base); + let candidate = rawBase + ext; + let idx = 1; + while (siblings.includes(candidate)) { + idx += 1; + candidate = `${rawBase} (${idx})${ext}`; + } + return candidate; + }, [findChildren]); + + // Load project tree on mount and when tab switches to project + useEffect(() => { + if (activeTab === 'project') { + refreshProjectTree().catch(() => {}); + } + }, [activeTab, refreshProjectTree]); + + // Load archived nodes on mount + useEffect(() => { + loadArchivedNodes().catch(() => {}); + }, [loadArchivedNodes]); + + // Load files when entering files tab + useEffect(() => { + if (activeTab === 'files') { + refreshFiles().catch(() => {}); + } + }, [activeTab, refreshFiles]); + + // Context menu handlers + const openContextMenu = (e: React.MouseEvent, item?: FSItem) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, item }); + }; + + const closeContextMenu = () => setContextMenu(null); + + const promptName = (message: string, defaultValue: string) => { + const val = window.prompt(message, defaultValue); + return val?.trim() || null; + }; + + const handleCreateFolder = async (base: string) => { + const input = promptName('Folder name', 'new-folder'); + if (!input) return; + const name = ensureUniqueName(input, base, true); + await createProjectFolder(joinPath(base, name)); + }; + + const handleNewBlueprint = async (base: string) => { + const input = promptName('Blueprint file name', 'untitled'); + if (!input) return; + const name = ensureUniqueName(input, base, false); + const path = joinPath(base, name); + // Create empty blueprint and save immediately + const empty: BlueprintDocument = { + version: 1, + nodes: [], + edges: [], + viewport: getViewport(), + theme, + }; + await saveBlueprintFile(path, empty.viewport); + await loadBlueprint(empty); + setCurrentBlueprintPath(path); + }; + + const handleRename = async (item: FSItem) => { + const newName = promptName('Rename to', item.name); + if (!newName || newName === item.name) return; + await renameProjectItem(item.path, newName); + }; + + const handleDelete = async (item: FSItem) => { + const currentPath = currentBlueprintPath; + const isDeletingOpen = + currentPath === item.path || + (item.type === 'folder' && currentPath && (currentPath === item.path || currentPath.startsWith(`${item.path}/`))); + const ok = window.confirm( + isDeletingOpen + ? `The opened blueprint is in this ${item.type}. Delete and clear canvas?` + : `Delete ${item.name}?` + ); + if (!ok) return; + await deleteProjectItem(item.path, item.type === 'folder'); + if (isDeletingOpen) { + clearBlueprint(); + } + await refreshProjectTree(); + }; + + const handleLoadFile = async (item: FSItem) => { + if (item.type !== 'file') return; + try { + const doc = await readBlueprintFile(item.path); + const vp = loadBlueprint(doc); + setCurrentBlueprintPath(item.path); + if (vp) { + setViewport(vp); + } + } catch (e) { + console.error(e); + alert('Not a valid blueprint JSON.'); + } + }; + + const handleDownload = async (item: FSItem) => { + if (item.type !== 'file') return; + const url = `${import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'}/api/projects/download?user=test&path=${encodeURIComponent(item.path)}`; + const a = document.createElement('a'); + a.href = url; + a.download = item.name; + a.click(); + }; + + const handleUploadClick = () => fileInputRef.current?.click(); + + const promptForPath = (base: string) => { + const input = window.prompt('Save as (filename without extension)', 'untitled')?.trim(); + if (!input) return null; + const name = ensureUniqueName(input, base, false); + return joinPath(base, name); + }; + + const handleSave = async () => { + let path = currentBlueprintPath; + if (!path) { + const p = promptForPath(currentFolder); + if (!p) return; + path = p; + setCurrentBlueprintPath(path); + } + const viewport = getViewport(); + await saveCurrentBlueprint(path, viewport); + }; + + const handleUploadFiles = async (files: FileList, targetFolder: string) => { + for (const file of Array.from(files)) { + if (!file.name.toLowerCase().endsWith('.json')) continue; + const text = await file.text(); + try { + const json = JSON.parse(text); + const viewport = json.viewport; + const uniqueName = ensureUniqueName(file.name, targetFolder, false); + await saveBlueprintFile(joinPath(targetFolder, uniqueName), viewport); + } catch { + // skip invalid json + } + } + await refreshProjectTree(); + }; + + // Files tab handlers + const handleFilesUpload = async (list: FileList) => { + let ok = 0; + let failed: string[] = []; + for (const f of Array.from(list)) { + try { + await uploadFile(f); + ok += 1; + } catch (e) { + console.error(e); + failed.push(`${f.name}: ${(e as Error).message}`); + } + } + await refreshFiles(); + if (failed.length) { + alert(`Some files failed:\n${failed.join('\n')}`); + } else if (ok > 0) { + // Optional: brief feedback + console.info(`Uploaded ${ok} file(s)`); + } + }; + + const handleFilesInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => { + const files = e.target.files; + if (files && files.length > 0) { + await handleFilesUpload(files); + e.target.value = ''; + } + }; + + const handleDownloadFile = (file: FileMeta) => { + const url = `${import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'}/api/files/download?user=test&file_id=${encodeURIComponent(file.id)}`; + const a = document.createElement('a'); + a.href = url; + a.download = file.name; + a.click(); + }; + + const formatSize = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; + }; + + // Fade-out for "Saved" indicator + useEffect(() => { + if (saveStatus === 'saved') { + setShowSaveStatus(true); + const t = window.setTimeout(() => setShowSaveStatus(false), 1000); + return () => window.clearTimeout(t); + } + if (saveStatus === 'saving' || saveStatus === 'error') { + setShowSaveStatus(true); + return; + } + setShowSaveStatus(false); + }, [saveStatus]); + + const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => { + const files = e.target.files; + if (files && files.length > 0) { + await handleUploadFiles(files, currentFolder); + e.target.value = ''; + } + }; + + // Drag move blueprint into folder + const onItemDragStart = (e: React.DragEvent, item: FSItem) => { + setDragItem(item); + e.dataTransfer.effectAllowed = 'move'; + }; + const onItemDragOver = (e: React.DragEvent, item: FSItem) => { + if (item.type === 'folder') { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + } + }; + const onItemDrop = async (e: React.DragEvent, target: FSItem) => { + e.preventDefault(); + if (!dragItem || target.type !== 'folder') return; + const newPath = joinPath(target.path, dragItem.name); + if (newPath === dragItem.path) return; + await renameProjectItem(dragItem.path, undefined, newPath); + setDragItem(null); + }; + + const toggleFolder = (path: string) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const renderTree = useCallback((items: FSItem[], depth = 0) => { + return items.map(item => { + const isActive = currentBlueprintPath === item.path; + const isExpanded = expanded.has(item.path); + const padding = depth * 12; + const hasChildren = (item.children?.length || 0) > 0; + return ( + <div key={item.path}> + <div + className={`flex items-center justify-between px-2 py-1 rounded cursor-pointer ${ + isActive + ? isDark ? 'bg-blue-900/40 border border-blue-700' : 'bg-blue-50 border-blue-200 border' + : isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-100' + }`} + style={{ paddingLeft: padding + 8 }} + onContextMenu={(e) => openContextMenu(e, item)} + onClick={() => { + if (item.type === 'folder') { + toggleFolder(item.path); + setCurrentFolder(item.path); + } else { + setCurrentFolder(item.path.split('/').slice(0, -1).join('/') || '.'); + } + }} + onDoubleClick={() => { + if (item.type === 'file') { + handleLoadFile(item); + setCurrentFolder(item.path.split('/').slice(0, -1).join('/') || '.'); + } + }} + draggable + onDragStart={(e) => onItemDragStart(e, item)} + onDragOver={(e) => onItemDragOver(e, item)} + onDrop={(e) => onItemDrop(e, item)} + > + <div className="flex items-center gap-2"> + {item.type === 'folder' ? ( + <button + className="w-4 text-left" + onClick={(e) => { e.stopPropagation(); toggleFolder(item.path); }} + title={hasChildren ? undefined : 'Empty folder'} + > + {isExpanded ? '▾' : '▸'} + </button> + ) : ( + <span className="w-4" /> + )} + {item.type === 'folder' ? <Folder size={14} /> : <FileText size={14} />} + <span className="truncate">{stripJson(item.name)}</span> + </div> + <button className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700" onClick={(e) => { e.stopPropagation(); openContextMenu(e as any, item); }}> + <MoreVertical size={14} /> + </button> + </div> + {item.type === 'folder' && isExpanded && item.children && item.children.length > 0 && ( + <div> + {renderTree(item.children, depth + 1)} + </div> + )} + </div> + ); + }); + }, [isDark, currentBlueprintPath, expanded, handleLoadFile]); + + if (!isOpen) { + return ( + <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 rounded mb-4 ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`} + title="Expand" + > + <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" : 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-[14%] min-w-[260px] max-w-[360px] 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' + }`} + onClick={() => setContextMenu(null)} + onContextMenu={(e) => { + // Default empty-area context menu + if (activeTab === 'project') { + openContextMenu(e); + } + }} + > + {/* Header */} + <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 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + > + <ChevronLeft size={16} className={isDark ? 'text-gray-400' : 'text-gray-500'} /> + </button> + </div> + + {/* Tabs */} + <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-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-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-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 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + {activeTab === 'project' && ( + <div + className="space-y-2" + > + <div + className="flex items-center gap-2 mb-2" + onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }} + > + <button + onClick={() => refreshProjectTree()} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="Refresh" + > + <RefreshCw size={14} /> + </button> + <button + onClick={() => handleNewBlueprint(currentFolder)} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="New Blueprint" + > + <Plus size={14} /> + </button> + <button + onClick={() => handleCreateFolder(currentFolder)} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="New Folder" + > + <Folder size={14} /> + </button> + <button + onClick={handleUploadClick} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="Upload Blueprint" + > + <Upload size={14} /> + </button> + <button + onClick={handleSave} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="Save (Ctrl+S)" + > + <Edit3 size={14} /> + </button> + <span + className={`text-xs transition-opacity duration-300 ${ + saveStatus === 'saved' ? 'text-green-500' : saveStatus === 'saving' ? 'text-blue-500' : saveStatus === 'error' ? 'text-red-500' : isDark ? 'text-gray-500' : 'text-gray-400' + }`} + style={{ opacity: showSaveStatus && saveStatus !== 'idle' ? 1 : 0 }} + > + {saveStatus === 'saved' ? 'Saved' : saveStatus === 'saving' ? 'Saving...' : saveStatus === 'error' ? 'Save failed' : ''} + </span> + <input + ref={fileInputRef} + type="file" + accept=".json,application/json" + multiple + className="hidden" + onChange={handleFileInputChange} + /> + </div> + {!currentBlueprintPath && ( + <div className={`text-xs italic ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> + No file open; Save will create a new file. + </div> + )} + + <div + className={`${isDark ? 'border-gray-700' : 'border-gray-200'} border-t border-dashed mx-1`} + onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }} + /> + + <div + onContextMenu={(e) => { + if (activeTab === 'project') { + openContextMenu(e); + } + }} + onDragOver={(e) => { + if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); } + }} + onDrop={async (e) => { + if (e.dataTransfer.files?.length) { + e.preventDefault(); + await handleUploadFiles(e.dataTransfer.files, currentFolder); + } + }} + > + {projectTree.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-40 opacity-50"> + <Folder size={32} className="mb-2" /> + <p className="text-xs text-center">No files. Right-click to add.</p> + </div> + ) : ( + <div className="space-y-1"> + {renderTree(projectTree, 0)} + </div> + )} + </div> + </div> + )} + {activeTab === 'files' && ( + <div + className="flex flex-col h-full gap-2" + onDragOver={(e) => { if (e.dataTransfer.types.includes('Files')) e.preventDefault(); }} + onDrop={async (e) => { + if (e.dataTransfer.files?.length) { + e.preventDefault(); + await handleFilesUpload(e.dataTransfer.files); + } + }} + > + <div className="flex items-center gap-2"> + <button + onClick={() => refreshFiles()} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="Refresh files" + > + <RefreshCw size={14} /> + </button> + <button + onClick={() => fileUploadRef.current?.click()} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="Upload files (drag & drop supported)" + > + <Upload size={14} /> + </button> + <input + ref={fileUploadRef} + type="file" + multiple + className="hidden" + onChange={handleFilesInputChange} + /> + <span className={`text-xs ${isDark ? 'text-gray-500' : 'text-gray-500'}`}>Drag files here or click upload</span> + </div> + + {files.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-full opacity-50 border border-dashed border-gray-300 dark:border-gray-700 rounded"> + <FileText size={32} className="mb-2" /> + <p className="text-xs text-center">No files uploaded yet.</p> + </div> + ) : ( + <div className="flex-1 overflow-y-auto space-y-1"> + {files.map(f => ( + <div + key={f.id} + className={`flex items-center justify-between px-2 py-1 rounded border ${isDark ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`} + > + <div className="flex flex-col"> + <span className="text-sm font-medium">{f.name}</span> + <span className={`text-[11px] ${isDark ? 'text-gray-500' : 'text-gray-500'}`}> + {formatSize(f.size)} • {new Date(f.created_at * 1000).toLocaleString()} + </span> + </div> + <div className="flex items-center gap-2"> + <button + onClick={() => handleDownloadFile(f)} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + title="Download" + > + <Download size={14} /> + </button> + <button + onClick={async () => { if (confirm('Delete this file?')) { await deleteFile(f.id); } }} + className={`p-1 rounded ${isDark ? 'hover:bg-red-900 text-red-300' : 'hover:bg-red-50 text-red-600'}`} + title="Delete" + > + <Trash2 size={14} /> + </button> + </div> + </div> + ))} + </div> + )} + </div> + )} + {activeTab === 'archive' && ( + <div className="space-y-2"> + {archivedNodes.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-40 opacity-50"> + <Archive size={32} className="mb-2" /> + <p className="text-xs text-center"> + No archived nodes.<br/> + Right-click a node → "Add to Archive" + </p> + </div> + ) : ( + <> + <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 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' + }`} + title={`Label: ${archived.label}\nModel: ${archived.model}\nSystem: ${archived.systemPrompt || '(empty)'}\nUser: ${(archived.userPrompt || '').slice(0,80)}${(archived.userPrompt || '').length>80?'…':''}\nResp: ${(archived.response || '').slice(0,80)}${(archived.response || '').length>80?'…':''}`} + > + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <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 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] mt-1 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>{archived.model}</div> + </div> + ))} + </> + )} + </div> + )} + </div> + + {/* Context Menu */} + {contextMenu && ( + <div + className={`fixed z-50 rounded-md shadow-lg border py-1 text-sm ${isDark ? 'bg-gray-800 border-gray-700 text-gray-200' : 'bg-white border-gray-200 text-gray-700'}`} + style={{ top: contextMenu.y, left: contextMenu.x }} + onClick={(e) => e.stopPropagation()} + > + {(() => { + const item = contextMenu.item; + const targetFolder = item + ? (item.type === 'folder' ? item.path : item.path.split('/').slice(0, -1).join('/') || '.') + : '.'; // empty area => root + const commonNew = ( + <> + <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleCreateFolder(targetFolder); }}>New Folder</button> + <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleNewBlueprint(targetFolder); }}>New Blueprint</button> + <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleUploadClick(); }}>Upload</button> + </> + ); + if (!item) { + return commonNew; + } + if (item.type === 'file') { + return ( + <> + {commonNew} + <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleDownload(item); }}>Download</button> + <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleRename(item); }}>Rename</button> + <button className="block w-full text-left px-3 py-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/40" onClick={() => { closeContextMenu(); handleDelete(item); }}>Delete</button> + </> + ); + } + // folder + return ( + <> + {commonNew} + <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleRename(item); }}>Rename</button> + <button className="block w-full text-left px-3 py-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/40" onClick={() => { closeContextMenu(); handleDelete(item); }}>Delete</button> + </> + ); + })()} + </div> + )} + </div> + ); +}; + +export default LeftSidebar; + diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index f62f3cb..3008ba3 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,25 +1,139 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; +import { useReactFlow } from 'reactflow'; 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 } from 'lucide-react'; +import { Play, Settings, Info, Save, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation } from 'lucide-react'; -const Sidebar = () => { - const { nodes, selectedNodeId, updateNodeData, getActiveContext } = useFlowStore(); +interface SidebarProps { + isOpen: boolean; + onToggle: () => void; + onInteract?: () => void; +} + +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 { setCenter } = useReactFlow(); + 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); + 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<Trace | null>(null); + const [quickChatLastNodeId, setQuickChatLastNodeId] = useState<string | null>(null); // Track the last node in the chat chain + 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); - // Reset stream buffer when node changes + // Reset stream buffer and modal states when node changes useEffect(() => { setStreamBuffer(''); + setIsModalOpen(false); + setIsEditing(false); + setShowMergeModal(false); + setMergeSelectedIds([]); + setShowMergePreview(false); }, [selectedNodeId]); + + // Default select first trace when node changes and no trace is selected + useEffect(() => { + if (selectedNode && + selectedNode.data.traces && + selectedNode.data.traces.length > 0 && + (!selectedNode.data.activeTraceIds || selectedNode.data.activeTraceIds.length === 0)) { + updateNodeData(selectedNode.id, { + activeTraceIds: [selectedNode.data.traces[0].id] + }); + } + }, [selectedNodeId, selectedNode?.data.traces?.length]); + + // Sync editedResponse when entering edit mode + useEffect(() => { + if (isEditing && selectedNode) { + setEditedResponse(selectedNode.data.response || ''); + } + }, [isEditing, selectedNode?.data.response]); + + // Scroll to bottom when quick chat messages change + useEffect(() => { + if (quickChatEndRef.current) { + quickChatEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [quickChatMessages]); + + if (!isOpen) { + return ( + <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 rounded mb-4 ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`} + title="Expand" + > + <ChevronLeft size={20} className={isDark ? 'text-gray-400' : 'text-gray-500'} /> + </button> + {selectedNode && ( + <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> + )} + </div> + ); + } if (!selectedNode) { return ( - <div className="w-96 border-l border-gray-200 h-screen p-4 bg-gray-50 text-gray-500 text-center flex flex-col justify-center"> + <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 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> ); } @@ -27,27 +141,43 @@ const Sidebar = () => { 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') ? 'openai' : 'google', + 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', } }) }); @@ -62,17 +192,15 @@ const Sidebar = () => { 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`, @@ -80,44 +208,906 @@ const Sidebar = () => { 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 + 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); } }; const handleChange = (field: keyof NodeData, value: any) => { updateNodeData(selectedNode.id, { [field]: value }); }; + + const handleSaveEdit = () => { + if (!selectedNode) return; + updateNodeData(selectedNode.id, { response: editedResponse }); + setIsEditing(false); + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setEditedResponse(selectedNode?.data.response || ''); + }; + + // Summarize response + const handleSummarize = async () => { + if (!selectedNode?.data.response) return; + + setIsSummarizing(true); + setShowSummaryModal(false); + + try { + const res = await fetch('http://localhost:8000/api/summarize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: selectedNode.data.response, + model: summaryModel + }) + }); + + if (res.ok) { + const data = await res.json(); + if (data.summary) { + // Replace response with summary + updateNodeData(selectedNode.id, { response: data.summary }); + } + } + } catch (error) { + console.error('Summarization failed:', error); + } finally { + setIsSummarizing(false); + } + }; + + // Auto-generate title using gpt-5-nano + const generateTitle = async (nodeId: string, userPrompt: string, response: string) => { + try { + const res = await fetch('http://localhost:8000/api/generate_title', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_prompt: userPrompt, response }) + }); + + if (res.ok) { + const data = await res.json(); + if (data.title) { + updateNodeData(nodeId, { label: data.title }); + } + } + } catch (error) { + console.error('Failed to generate title:', error); + // Silently fail - keep the original title + } + }; + + // Open merge modal + const openMergeModal = () => { + if (!selectedNode?.data.traces) return; + const traceIds = selectedNode.data.traces.map((t: Trace) => t.id); + setMergeOrder(traceIds); + setMergeSelectedIds([]); + setShowMergePreview(false); + setShowMergeModal(true); + }; + + // Drag-and-drop handlers for merge modal + const handleMergeDragStart = (e: React.DragEvent, traceId: string) => { + setMergeDraggedId(traceId); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleMergeDragOver = (e: React.DragEvent, overTraceId: string) => { + e.preventDefault(); + if (!mergeDraggedId || mergeDraggedId === overTraceId) return; + + const newOrder = [...mergeOrder]; + const draggedIndex = newOrder.indexOf(mergeDraggedId); + const overIndex = newOrder.indexOf(overTraceId); + + if (draggedIndex !== -1 && overIndex !== -1) { + newOrder.splice(draggedIndex, 1); + newOrder.splice(overIndex, 0, mergeDraggedId); + setMergeOrder(newOrder); + } + }; + + const handleMergeDragEnd = () => { + setMergeDraggedId(null); + }; + + // Toggle trace selection in merge modal + const toggleMergeSelection = (traceId: string) => { + setMergeSelectedIds(prev => { + if (prev.includes(traceId)) { + return prev.filter(id => id !== traceId); + } else { + return [...prev, traceId]; + } + }); + }; + + // Create merged trace + const handleCreateMergedTrace = async () => { + if (!selectedNode || mergeSelectedIds.length < 2) return; + + // Get the ordered trace IDs based on mergeOrder + const orderedSelectedIds = mergeOrder.filter(id => mergeSelectedIds.includes(id)); + + if (mergeStrategy === 'summary') { + setIsSummarizingMerge(true); + try { + const messages = computeMergedMessages(selectedNode.id, orderedSelectedIds, 'trace_order'); + const content = messages.map(m => `${m.role}: ${m.content}`).join('\n\n'); + + const res = await fetch('http://localhost:8000/api/summarize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content, + model_name: 'gpt-5-nano', + api_key: selectedNode.data.apiKey + }) + }); + + if (res.ok) { + const data = await res.json(); + const mergedId = createMergedTrace(selectedNode.id, orderedSelectedIds, 'summary'); + if (mergedId && data.summary) { + updateMergedTrace(selectedNode.id, mergedId, { summarizedContent: data.summary }); + } + } + } catch (error) { + console.error('Failed to summarize for merge:', error); + } finally { + setIsSummarizingMerge(false); + } + } else { + createMergedTrace(selectedNode.id, orderedSelectedIds, mergeStrategy); + } + + // Close modal and reset + setShowMergeModal(false); + setMergeSelectedIds([]); + setShowMergePreview(false); + }; + + // Get preview of merged messages + const getMergePreview = () => { + if (!selectedNode || mergeSelectedIds.length < 2) return []; + const orderedSelectedIds = mergeOrder.filter(id => mergeSelectedIds.includes(id)); + return computeMergedMessages(selectedNode.id, orderedSelectedIds, mergeStrategy); + }; + + // Check if a trace has downstream nodes from the current selected node + const traceHasDownstream = (trace: Trace): boolean => { + if (!selectedNode) return false; + + // Find edges going out from selectedNode that are part of this trace + const outgoingEdge = edges.find(e => + e.source === selectedNode.id && + e.sourceHandle?.startsWith('trace-') + ); + + return !!outgoingEdge; + }; + + // Quick Chat functions + const openQuickChat = (trace: Trace | null, isNewTrace: boolean = false) => { + if (!selectedNode) return; + onInteract?.(); // Close context menu when opening quick chat + + // Check if current node has a "sent" query (has response) or just unsent draft + const hasResponse = !!selectedNode.data.response; + const hasDraftPrompt = !!selectedNode.data.userPrompt && !hasResponse; + + if (isNewTrace || !trace) { + // Start a new trace from current node + const initialMessages: Message[] = []; + // Only include user prompt as message if it was actually sent (has response) + if (selectedNode.data.userPrompt && hasResponse) { + initialMessages.push({ id: `${selectedNode.id}-u`, role: 'user', content: selectedNode.data.userPrompt }); + } + if (selectedNode.data.response) { + initialMessages.push({ id: `${selectedNode.id}-a`, role: 'assistant', content: selectedNode.data.response }); + } + + setQuickChatTrace({ + id: `new-trace-${selectedNode.id}`, + sourceNodeId: selectedNode.id, + color: '#888', + messages: initialMessages + }); + setQuickChatMessages(initialMessages); + setQuickChatNeedsDuplicate(false); + 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); + + // Set last node ID: if current node has response, start from here. + // Otherwise start from trace source (which is the last completed node) + setQuickChatLastNodeId(hasResponse ? selectedNode.id : trace.sourceNodeId); + } + + setQuickChatOpen(true); + // 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; + }; + + // 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<string> = 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<string> = 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 () => { + 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 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, + 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: [], + 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 ( - <div className="w-96 border-l border-gray-200 h-screen flex flex-col bg-white shadow-xl z-10"> + <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"> - <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" - /> + <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 ${ + isDark ? 'text-gray-200' : 'text-gray-900' + }`} + /> + <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'}`} @@ -146,30 +1136,134 @@ const Sidebar = () => { <label className="block text-sm font-medium text-gray-700 mb-1">Model</label> <select value={selectedNode.data.model} - onChange={(e) => handleChange('model', e.target.value)} + onChange={(e) => { + const newModel = e.target.value; + // Auto-set temperature to 1 for reasoning models + 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(newModel); + + if (isReasoning) { + handleChange('temperature', 1); + } + handleChange('model', newModel); + }} className="w-full border border-gray-300 rounded-md p-2 text-sm" > - <option value="gpt-4o">GPT-4o</option> - <option value="gpt-4o-mini">GPT-4o Mini</option> - <option value="gemini-1.5-pro">Gemini 1.5 Pro</option> - <option value="gemini-1.5-flash">Gemini 1.5 Flash</option> + <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> </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" @@ -177,49 +1271,304 @@ const Sidebar = () => { 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"> + {trace.isMerged ? ( + // Merged Trace Rendering (for propagated merged traces) + <div className="flex -space-x-1 shrink-0"> + {(trace.mergedColors || [trace.color]).slice(0, 3).map((color, idx) => ( + <div + key={idx} + className="w-2 h-2 rounded-full border-2" + style={{ backgroundColor: color, borderColor: isDark ? '#1f2937' : '#fff' }} + /> + ))} + {(trace.mergedColors?.length || 0) > 3 && ( + <div className={`w-2 h-2 rounded-full flex items-center justify-center text-[6px] ${ + isDark ? 'bg-gray-700 text-gray-400' : 'bg-gray-200 text-gray-500' + }`}> + + + </div> + )} + </div> + ) : ( + // Regular Trace Rendering + <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.isMerged ? 'Merged ' : ''}#{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); + + // 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 ( + <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={`flex items-center gap-1 ${isDark ? 'text-gray-300' : 'text-gray-600'}`}> + <span className="font-mono truncate">Merged #{merged.id.slice(-6)}</span> + {!isComplete && ( + <span className="text-[9px] text-orange-500 font-sans">(incomplete)</span> + )} + </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); + }} + disabled={!isComplete} + className={`p-1 rounded shrink-0 ${ + isComplete + ? isDark ? 'hover:bg-purple-900 text-gray-500 hover:text-purple-400' : 'hover:bg-purple-100 text-gray-400 hover:text-purple-600' + : 'text-gray-500 cursor-not-allowed opacity-50' + }`} + title={isComplete ? "Quick Chat with merged context" : "Trace incomplete"} + > + <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} className="flex-shrink-0" /> + <span className="flex-1">Upstream node is empty. Complete the context chain before running.</span> + <button + onClick={navigateToEmptyNode} + className={`flex-shrink-0 p-1 rounded hover:bg-yellow-600/30 transition-colors`} + title="Go to empty node" + > + <Navigation size={14} /> + </button> + </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 </button> <div className="mt-6"> - <label className="block text-sm font-medium text-gray-700 mb-2">Response</label> - <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="flex items-center justify-between mb-2"> + <label className="block text-sm font-medium text-gray-700">Response</label> + <div className="flex gap-1"> + {selectedNode.data.response && ( + <> + <button + onClick={() => setShowSummaryModal(true)} + disabled={isSummarizing} + className="p-1 hover:bg-gray-200 rounded text-gray-500 hover:text-gray-700 disabled:opacity-50" + title="Summarize" + > + {isSummarizing ? <Loader2 className="animate-spin" size={14} /> : <FileText size={14} />} + </button> + <button + onClick={() => setIsEditing(true)} + className="p-1 hover:bg-gray-200 rounded text-gray-500 hover:text-gray-700" + title="Edit Response" + > + <Edit3 size={14} /> + </button> + <button + onClick={() => setIsModalOpen(true)} + className="p-1 hover:bg-gray-200 rounded text-gray-500 hover:text-gray-700" + title="Expand" + > + <Maximize2 size={14} /> + </button> + </> + )} </div> + </div> + + {isEditing ? ( + <div className="space-y-2"> + <textarea + value={editedResponse} + onChange={(e) => setEditedResponse(e.target.value)} + className={`w-full border rounded-md p-2 text-sm min-h-[200px] font-mono focus:ring-2 focus:ring-blue-500 ${ + isDark + ? 'bg-gray-800 border-gray-600 text-gray-200 placeholder-gray-500' + : 'bg-white border-blue-300 text-gray-900' + }`} + /> + <div className="flex gap-2 justify-end"> + <button + onClick={handleCancelEdit} + className={`px-3 py-1 text-sm rounded flex items-center gap-1 ${ + isDark ? 'text-gray-400 hover:bg-gray-800' : 'text-gray-600 hover:bg-gray-100' + }`} + > + <X size={14} /> Cancel + </button> + <button + onClick={handleSaveEdit} + className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-1" + > + <Check size={14} /> Save + </button> + </div> + </div> + ) : ( + <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 text-gray-200' + : 'bg-gray-50 border-gray-200 text-gray-900' + }`}> + <ReactMarkdown>{selectedNode.data.response || (streamingNodeId === selectedNode.id ? streamBuffer : '')}</ReactMarkdown> + </div> + )} </div> </div> )} @@ -227,22 +1576,32 @@ const Sidebar = () => { {activeTab === 'settings' && ( <div className="space-y-4"> <div> - <label className="block text-sm font-medium text-gray-700 mb-1">Merge Strategy</label> + <label className={`block text-sm font-medium mb-1 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>Merge Strategy</label> <select value={selectedNode.data.mergeStrategy || 'smart'} onChange={(e) => handleChange('mergeStrategy', e.target.value)} - className="w-full border border-gray-300 rounded-md p-2 text-sm" + className={`w-full border rounded-md p-2 text-sm ${ + isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300 bg-white text-gray-900' + }`} > <option value="smart">Smart (Auto-merge roles)</option> <option value="raw">Raw (Concatenate)</option> </select> - <p className="text-xs text-gray-500 mt-1"> + <p className={`text-xs mt-1 ${isDark ? 'text-gray-500' : 'text-gray-500'}`}> Smart merge combines consecutive messages from the same role to avoid API errors. </p> </div> <div> - <label className="block text-sm font-medium text-gray-700 mb-1">Temperature ({selectedNode.data.temperature})</label> + <label className={`block text-sm font-medium mb-1 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}> + Temperature ({selectedNode.data.temperature}) + {[ + '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(selectedNode.data.model) && ( + <span className="text-xs text-orange-500 ml-2">(Locked for Reasoning Model)</span> + )} + </label> <input type="range" min="0" @@ -250,9 +1609,37 @@ const Sidebar = () => { step="0.1" value={selectedNode.data.temperature} onChange={(e) => handleChange('temperature', parseFloat(e.target.value))} - className="w-full" + disabled={[ + '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(selectedNode.data.model)} + className="w-full disabled:opacity-50 disabled:cursor-not-allowed" /> </div> + + {/* Reasoning Effort - Only for OpenAI reasoning models (except chat-latest) */} + {[ + 'gpt-5', 'gpt-5-mini', 'gpt-5-nano', + 'gpt-5-pro', 'gpt-5.1', 'o3' + ].includes(selectedNode.data.model) && ( + <div> + <label className="block text-sm font-medium text-gray-700 mb-1"> + Reasoning Effort + </label> + <select + value={selectedNode.data.reasoningEffort || 'medium'} + onChange={(e) => handleChange('reasoningEffort', e.target.value)} + className="w-full border border-gray-300 rounded-md p-2 text-sm" + > + <option value="low">Low (Faster, less thorough)</option> + <option value="medium">Medium (Balanced)</option> + <option value="high">High (Slower, more thorough)</option> + </select> + <p className="text-xs text-gray-500 mt-1"> + Controls how much reasoning the model performs before responding. Higher = more tokens used. + </p> + </div> + )} <div> <label className="block text-sm font-medium text-gray-700 mb-1">API Key (Optional)</label> @@ -274,47 +1661,594 @@ const Sidebar = () => { placeholder="Global system prompt will be used if empty..." /> </div> + + {(selectedNode.data.model.startsWith('gemini') || + selectedNode.data.model.startsWith('gpt-5') || + ['o3', 'o4-mini', 'gpt-4o'].includes(selectedNode.data.model)) && ( + <div className="flex items-center gap-2 mt-4"> + <input + type="checkbox" + id="web-search" + checked={selectedNode.data.enableGoogleSearch !== false} // Default to true + onChange={(e) => handleChange('enableGoogleSearch', e.target.checked)} + /> + <label htmlFor="web-search" className="text-sm font-medium text-gray-700 select-none cursor-pointer"> + Enable Web Search + </label> + </div> + )} </div> )} {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> </div> )} </div> + + {/* Response Modal */} + {isModalOpen && selectedNode && ( + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setIsModalOpen(false)}> + <div + className="bg-white rounded-lg shadow-2xl w-[80vw] max-w-4xl max-h-[80vh] flex flex-col" + onClick={(e) => e.stopPropagation()} + > + {/* Modal Header */} + <div className="flex items-center justify-between p-4 border-b border-gray-200"> + <h3 className="font-semibold text-lg">{selectedNode.data.label} - Response</h3> + <div className="flex gap-2"> + {!isEditing && ( + <button + onClick={() => setIsEditing(true)} + className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded flex items-center gap-1" + > + <Edit3 size={14} /> Edit + </button> + )} + <button + onClick={() => { setIsModalOpen(false); setIsEditing(false); }} + className="p-1 hover:bg-gray-200 rounded text-gray-500" + > + <X size={18} /> + </button> + </div> + </div> + + {/* Modal Content */} + <div className="flex-1 overflow-y-auto p-6"> + {isEditing ? ( + <textarea + value={editedResponse} + onChange={(e) => setEditedResponse(e.target.value)} + className="w-full h-full min-h-[400px] border border-gray-300 rounded-md p-3 text-sm font-mono focus:ring-2 focus:ring-blue-500 resize-y" + /> + ) : ( + <div className="prose prose-sm max-w-none"> + <ReactMarkdown>{selectedNode.data.response}</ReactMarkdown> + </div> + )} + </div> + + {/* Modal Footer (only when editing) */} + {isEditing && ( + <div className="flex justify-end gap-2 p-4 border-t border-gray-200"> + <button + onClick={handleCancelEdit} + className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded flex items-center gap-1" + > + <X size={14} /> Cancel + </button> + <button + onClick={() => { handleSaveEdit(); setIsModalOpen(false); }} + className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-1" + > + <Check size={14} /> Save Changes + </button> + </div> + )} + </div> + </div> + )} + + {/* Summary Model Selection Modal */} + {showSummaryModal && ( + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowSummaryModal(false)}> + <div + className="bg-white rounded-lg shadow-2xl w-80 p-4" + onClick={(e) => e.stopPropagation()} + > + <h3 className="font-semibold text-lg mb-4">Summarize Response</h3> + + <div className="mb-4"> + <label className="block text-sm font-medium text-gray-700 mb-2">Select Model</label> + <select + value={summaryModel} + onChange={(e) => setSummaryModel(e.target.value)} + className="w-full border border-gray-300 rounded-md p-2 text-sm" + > + <optgroup label="Fast (Recommended)"> + <option value="gpt-5-nano">gpt-5-nano</option> + <option value="gpt-5-mini">gpt-5-mini</option> + <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option> + <option value="gemini-2.5-flash">gemini-2.5-flash</option> + </optgroup> + <optgroup label="Standard"> + <option value="gpt-4o">gpt-4o</option> + <option value="gpt-5">gpt-5</option> + </optgroup> + </select> + </div> + + <div className="flex justify-end gap-2"> + <button + onClick={() => setShowSummaryModal(false)} + className="px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded" + > + Cancel + </button> + <button + onClick={handleSummarize} + className="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-1" + > + <FileText size={14} /> Summarize + </button> + </div> + </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; + const traceColors = trace.isMerged && trace.mergedColors && trace.mergedColors.length > 0 + ? trace.mergedColors + : [trace.color]; + + 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="flex -space-x-1"> + {traceColors.slice(0, 3).map((c, idx) => ( + <div + key={idx} + className="w-3 h-3 rounded-full border-2" + style={{ backgroundColor: c, borderColor: isDark ? '#1f2937' : '#fff' }} + /> + ))} + {traceColors.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' + }`}> + +{traceColors.length - 3} + </div> + )} + </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> + )} + + {/* 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> -// 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> -); + {/* 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 cdd402c..d2e1293 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 @@ -49,55 +50,247 @@ 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 bg-white border-2 min-w-[200px] ${selected ? 'border-blue-500' : 'border-gray-200'}`}> + <div + className={`px-4 py-2 shadow-md rounded-md border-2 min-w-[200px] transition-all relative ${ + isDisabled + ? isDark + ? 'bg-gray-800 border-gray-600 opacity-50 cursor-not-allowed' + : 'bg-gray-100 border-gray-300 opacity-50 cursor-not-allowed' + : selected + ? 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 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 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]">{data.label}</div> - <div className="text-xs text-gray-500">{data.model}</div> + <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 ${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 - REMOVED per user request */} + {/* Users should prepend to parent traces instead */} </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 + // Now that trace IDs don't evolve, we check the base ID + const hasOutgoingEdge = edges.some(e => + e.source === id && + e.sourceHandle === `trace-${trace.id}` + ); + // Only show dashed handle if not yet connected + return !hasOutgoingEdge; + }) + .map((trace: Trace) => { + // Handle merged trace visualization + let backgroundStyle = trace.color; + if (trace.isMerged && trace.mergedColors && trace.mergedColors.length > 0) { + const colors = trace.mergedColors; + const gradientStops = colors.map((color, idx) => + `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%` + ).join(', '); + backgroundStyle = `linear-gradient(45deg, ${gradientStops})`; + } + + 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-${trace.id}`} + className="!w-3 !h-3 !right-[-6px]" + style={{ + background: backgroundStyle, + 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 => { + // Check if this is a locally created merged trace (should be shown in Part 2) + const isLocallyMerged = data.mergedTraces?.some(m => m.id === trace.id); + if (isLocallyMerged) 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) => { + // Handle merged trace visualization + let backgroundStyle = trace.color; + if (trace.isMerged && trace.mergedColors && trace.mergedColors.length > 0) { + const colors = trace.mergedColors; + const gradientStops = colors.map((color, idx) => + `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%` + ).join(', '); + backgroundStyle = `linear-gradient(45deg, ${gradientStops})`; + } + + return ( <div key={trace.id} className="relative h-4 w-4 my-1" title={`Trace: ${trace.id}`}> <Handle type="source" @@ -105,15 +298,48 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { id={`trace-${trace.id}`} className="!w-3 !h-3 !right-[-6px]" style={{ - backgroundColor: trace.color, + background: backgroundStyle, top: '50%', transform: 'translateY(-50%)' }} /> </div> - ))} + ); + })} + + {/* 2. Merged Trace Handles (with alternating color stripes) */} + {data.mergedTraces && data.mergedTraces.map((merged: MergedTrace) => { + // Check if this merged trace has any outgoing edges + const hasOutgoingEdge = edges.some(e => + e.source === id && e.sourceHandle === `trace-${merged.id}` + ); + + // 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]" + style={{ + background: stripeGradient, + top: '50%', + transform: 'translateY(-50%)', + border: hasOutgoingEdge ? 'none' : `2px dashed ${isDark ? '#374151' : '#fff'}` + }} + /> + </div> + ); + })} - {/* 2. New Branch Generator Handle (Always visible) */} + {/* 3. New Branch Generator Handle (Always visible) */} <div className="relative h-4 w-4 my-1" title="Create New Branch"> <Handle type="source" @@ -122,7 +348,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> diff --git a/frontend/src/index.css b/frontend/src/index.css index 91221be..16d39fe 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2,10 +2,190 @@ @tailwind components; @tailwind utilities; +:root { + /* Light theme */ + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f3f4f6; + --bg-canvas: #f8fafc; + --text-primary: #111827; + --text-secondary: #374151; + --text-muted: #6b7280; + --border-color: #e5e7eb; + --border-light: #f3f4f6; + --accent-blue: #3b82f6; + --accent-blue-hover: #2563eb; + --node-bg: #ffffff; + --node-border: #e5e7eb; + --node-selected: #3b82f6; +} + +.dark { + /* Dark theme */ + --bg-primary: #1f2937; + --bg-secondary: #111827; + --bg-tertiary: #374151; + --bg-canvas: #0f172a; + --text-primary: #f9fafb; + --text-secondary: #e5e7eb; + --text-muted: #9ca3af; + --border-color: #374151; + --border-light: #4b5563; + --accent-blue: #60a5fa; + --accent-blue-hover: #3b82f6; + --node-bg: #1f2937; + --node-border: #374151; + --node-selected: #60a5fa; +} + html, body, #root { height: 100%; width: 100%; margin: 0; padding: 0; overflow: hidden; -}
\ No newline at end of file +} + +/* ReactFlow dark mode overrides */ +.dark .react-flow__background { + background-color: var(--bg-canvas); +} + +.dark .react-flow__controls button { + background-color: var(--bg-primary); + border-color: var(--border-color); + color: var(--text-primary); +} + +.dark .react-flow__controls button:hover { + background-color: var(--bg-tertiary); +} + +.dark .react-flow__minimap { + background-color: var(--bg-secondary); +} + +.dark .react-flow__edge-path { + stroke: var(--text-muted); +} + +/* Dark mode form elements */ +.dark input, +.dark textarea, +.dark select { + background-color: var(--bg-tertiary) !important; + border-color: var(--border-color) !important; + color: var(--text-primary) !important; +} + +.dark input::placeholder, +.dark textarea::placeholder { + color: var(--text-muted) !important; +} + +.dark input:focus, +.dark textarea:focus, +.dark select:focus { + border-color: var(--accent-blue) !important; + ring-color: var(--accent-blue) !important; +} + +/* Dark mode labels and text */ +.dark label { + color: var(--text-secondary); +} + +/* Dark mode buttons */ +.dark .bg-blue-600 { + background-color: #2563eb; +} + +.dark .bg-blue-600:hover { + background-color: #1d4ed8; +} + +.dark .bg-gray-50 { + background-color: var(--bg-secondary); +} + +.dark .bg-gray-100 { + background-color: var(--bg-tertiary); +} + +.dark .text-gray-700 { + color: var(--text-secondary); +} + +.dark .text-gray-600 { + color: var(--text-muted); +} + +.dark .text-gray-500 { + color: var(--text-muted); +} + +.dark .border-gray-200 { + border-color: var(--border-color); +} + +.dark .border-gray-300 { + border-color: var(--border-color); +} + +/* Dark mode modals and overlays */ +.dark .bg-white { + background-color: var(--bg-primary); +} + +/* Quick Chat specific dark mode */ +.dark .bg-blue-500 { + background-color: #3b82f6; +} + +.dark .bg-gray-100 { + background-color: var(--bg-tertiary); +} + +.dark .text-gray-800 { + color: var(--text-primary); +} + +/* Dark mode hover states */ +.dark .hover\:bg-gray-50:hover { + background-color: var(--bg-tertiary) !important; +} + +.dark .hover\:bg-gray-100:hover { + background-color: var(--bg-tertiary) !important; +} + +.dark .hover\:bg-gray-200:hover { + background-color: #4b5563 !important; +} + +.dark .hover\:text-gray-900:hover { + color: var(--text-primary) !important; +} + +.dark .hover\:bg-blue-50:hover { + background-color: rgba(59, 130, 246, 0.2) !important; +} + +.dark .hover\:bg-blue-100:hover { + background-color: rgba(59, 130, 246, 0.3) !important; +} + +/* Dark mode for radio/checkbox */ +.dark input[type="radio"], +.dark input[type="checkbox"] { + accent-color: var(--accent-blue); +} + +/* Hide the selection box that wraps selected nodes */ +.react-flow__nodesselection-rect { + display: none !important; +} + +.react-flow__nodesselection { + display: none !important; +} diff --git a/frontend/src/store/flowStore.ts b/frontend/src/store/flowStore.ts index d2114aa..a049a8a 100644 --- a/frontend/src/store/flowStore.ts +++ b/frontend/src/store/flowStore.ts @@ -15,12 +15,39 @@ import { getOutgoers } from 'reactflow'; +// --- Project / Blueprint types --- +export interface ViewportState { + x: number; + y: number; + zoom: number; +} + +export interface BlueprintDocument { + version: number; + nodes: Node<NodeData>[]; + edges: Edge[]; + viewport?: ViewportState; + theme?: 'light' | 'dark'; +} + +export interface FSItem { + name: string; + path: string; // path relative to user root + type: 'file' | 'folder'; + size?: number | null; + mtime?: number | null; + children?: FSItem[]; +} + export type NodeStatus = 'idle' | 'loading' | 'success' | 'error'; +export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; export interface Message { id?: string; role: 'user' | 'assistant' | 'system'; content: string; + sourceTraceId?: string; // For merged traces: which trace this message came from + sourceTraceColor?: string; // For merged traces: color of the source trace } export interface Trace { @@ -28,6 +55,24 @@ export interface Trace { sourceNodeId: string; color: string; messages: Message[]; + // Optional merged trace info for visual propagation + isMerged?: boolean; + mergedColors?: string[]; + sourceTraceIds?: string[]; +} + +// Merge strategy types +export type MergeStrategy = 'query_time' | 'response_time' | 'trace_order' | 'grouped' | 'interleaved' | 'summary'; + +// Merged trace - combines multiple traces with a strategy +export interface MergedTrace { + id: string; + sourceNodeId: string; + sourceTraceIds: string[]; // IDs of traces being merged (in order for trace_order) + strategy: MergeStrategy; + colors: string[]; // Colors from source traces (for alternating display) + messages: Message[]; // Computed merged messages + summarizedContent?: string; // For summary strategy, stores the LLM-generated summary } export interface NodeData { @@ -38,31 +83,74 @@ export interface NodeData { systemPrompt: string; userPrompt: string; mergeStrategy: 'raw' | 'smart'; + enableGoogleSearch?: boolean; + reasoningEffort: 'low' | 'medium' | 'high'; // For OpenAI reasoning models + disabled?: boolean; // Greyed out, no interaction // Traces logic traces: Trace[]; // INCOMING Traces - outgoingTraces: Trace[]; // ALL Outgoing (inherited + self + forks) + outgoingTraces: Trace[]; // ALL Outgoing (inherited + self + forks + merged) forkedTraces: Trace[]; // Manually created forks from "New" handle + mergedTraces: MergedTrace[]; // Merged traces from multiple inputs activeTraceIds: string[]; response: string; status: NodeStatus; - inputs: number; + inputs: number; + + // Timestamps for merge logic + querySentAt?: number; // Unix timestamp when query was sent + responseReceivedAt?: number; // Unix timestamp when response was received + [key: string]: any; } export type LLMNode = Node<NodeData>; +// Archived node template (for reuse) +export interface ArchivedNode { + id: string; + label: string; + model: string; + systemPrompt: string; + temperature: number; + reasoningEffort: 'low' | 'medium' | 'high'; + userPrompt?: string; + response?: string; + enableGoogleSearch?: boolean; + mergeStrategy?: 'raw' | 'smart'; +} + +export interface FileMeta { + id: string; + name: string; + size: number; + mime: string; + created_at: number; + provider?: string; + provider_file_id?: string; +} + interface FlowState { nodes: LLMNode[]; edges: Edge[]; selectedNodeId: string | null; + archivedNodes: ArchivedNode[]; // Stored node templates + files: FileMeta[]; + theme: 'light' | 'dark'; + projectTree: FSItem[]; + currentBlueprintPath?: string; + lastViewport?: ViewportState; + saveStatus: SaveStatus; onNodesChange: OnNodesChange; onEdgesChange: OnEdgesChange; onConnect: OnConnect; addNode: (node: LLMNode) => void; + toggleTheme: () => void; + autoLayout: () => void; + findNonOverlappingPosition: (baseX: number, baseY: number) => { x: number; y: number }; updateNodeData: (nodeId: string, data: Partial<NodeData>) => void; setSelectedNode: (nodeId: string | null) => void; @@ -72,6 +160,68 @@ interface FlowState { deleteEdge: (edgeId: string) => void; deleteNode: (nodeId: string) => void; deleteBranch: (startNodeId?: string, startEdgeId?: string) => void; + deleteTrace: (startEdgeId: string) => void; + + // Archive actions + toggleNodeDisabled: (nodeId: string) => void; + archiveNode: (nodeId: string) => void; + removeFromArchive: (archiveId: string) => void; + createNodeFromArchive: (archiveId: string, position: { x: number; y: number }) => void; + + // Trace disable + toggleTraceDisabled: (edgeId: string) => void; + updateEdgeStyles: () => void; + + // Quick Chat helpers + isTraceComplete: (trace: Trace) => boolean; + getTraceNodeIds: (trace: Trace) => string[]; + createQuickChatNode: ( + fromNodeId: string, + trace: Trace | null, + userPrompt: string, + response: string, + model: string, + config: Partial<NodeData> + ) => string; // Returns new node ID + + // Blueprint serialization / persistence + serializeBlueprint: (viewport?: ViewportState) => BlueprintDocument; + loadBlueprint: (doc: BlueprintDocument) => ViewportState | undefined; + saveBlueprintFile: (path: string, viewport?: ViewportState) => Promise<void>; + readBlueprintFile: (path: string) => Promise<BlueprintDocument>; + refreshProjectTree: () => Promise<FSItem[]>; + createProjectFolder: (path: string) => Promise<void>; + renameProjectItem: (path: string, newName?: string, newPath?: string) => Promise<void>; + deleteProjectItem: (path: string, isFolder?: boolean) => Promise<void>; + setCurrentBlueprintPath: (path?: string) => void; + setLastViewport: (viewport: ViewportState) => void; + saveCurrentBlueprint: (path?: string, viewport?: ViewportState) => Promise<void>; + clearBlueprint: () => void; + loadArchivedNodes: () => Promise<void>; + saveArchivedNodes: () => Promise<void>; + refreshFiles: () => Promise<void>; + uploadFile: (file: File) => Promise<FileMeta>; + deleteFile: (fileId: string) => Promise<void>; + setFiles: (files: FileMeta[]) => void; + + // Merge trace functions + createMergedTrace: ( + nodeId: string, + sourceTraceIds: string[], + strategy: MergeStrategy + ) => string; // Returns merged trace ID + updateMergedTrace: ( + nodeId: string, + mergedTraceId: string, + updates: { sourceTraceIds?: string[]; strategy?: MergeStrategy; summarizedContent?: string } + ) => void; + deleteMergedTrace: (nodeId: string, mergedTraceId: string) => void; + computeMergedMessages: ( + nodeId: string, + sourceTraceIds: string[], + strategy: MergeStrategy, + tracesOverride?: Trace[] + ) => Message[]; propagateTraces: () => void; } @@ -86,15 +236,175 @@ const getStableColor = (str: string) => { return `hsl(${hue}, 70%, 60%)`; }; -const useFlowStore = create<FlowState>((set, get) => ({ +const API_BASE = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'; +const DEFAULT_USER = 'test'; + +const jsonFetch = async <T>(url: string, options?: RequestInit): Promise<T> => { + const res = await fetch(url, options); + if (!res.ok) { + const detail = await res.text(); + throw new Error(detail || `Request failed: ${res.status}`); + } + return res.json() as Promise<T>; +}; + +const useFlowStore = create<FlowState>((set, get) => { + + const validateBlueprint = (doc: any): BlueprintDocument => { + if (!doc || typeof doc !== 'object') throw new Error('Invalid blueprint: not an object'); + if (typeof doc.version !== 'number') throw new Error('Invalid blueprint: missing version'); + if (!Array.isArray(doc.nodes) || !Array.isArray(doc.edges)) throw new Error('Invalid blueprint: nodes/edges missing'); + return doc as BlueprintDocument; + }; + + return { nodes: [], edges: [], selectedNodeId: null, + archivedNodes: [], + files: [], + theme: 'light' as const, + projectTree: [], + currentBlueprintPath: undefined, + lastViewport: undefined, + saveStatus: 'idle', + + toggleTheme: () => { + const newTheme = get().theme === 'light' ? 'dark' : 'light'; + set({ theme: newTheme }); + // Update document class for global CSS + if (newTheme === 'dark') { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + }, + + setLastViewport: (viewport: ViewportState) => { + set({ lastViewport: viewport }); + }, + + setFiles: (files: FileMeta[]) => { + set({ files }); + }, + findNonOverlappingPosition: (baseX: number, baseY: number) => { + const { nodes } = get(); + // Estimate larger dimensions to be safe, considering dynamic handles + const nodeWidth = 300; + const nodeHeight = 200; + const padding = 20; + + let x = baseX; + let y = baseY; + let attempts = 0; + const maxAttempts = 100; // Increase attempts + + const isOverlapping = (testX: number, testY: number) => { + return nodes.some(node => { + const nodeX = node.position.x; + const nodeY = node.position.y; + return !(testX + nodeWidth + padding < nodeX || + testX > nodeX + nodeWidth + padding || + testY + nodeHeight + padding < nodeY || + testY > nodeY + nodeHeight + padding); + }); + }; + + // Try positions in a spiral pattern + while (isOverlapping(x, y) && attempts < maxAttempts) { + attempts++; + // Spiral parameters + const angle = attempts * 0.5; // Slower rotation + const radius = 50 + attempts * 30; // Faster expansion + + x = baseX + Math.cos(angle) * radius; + y = baseY + Math.sin(angle) * radius; + } + + return { x, y }; + }, + + autoLayout: () => { + const { nodes, edges } = get(); + if (nodes.length === 0) return; + + // Find root nodes (no incoming edges) + const nodesWithIncoming = new Set(edges.map(e => e.target)); + const rootNodes = nodes.filter(n => !nodesWithIncoming.has(n.id)); + + // BFS to layout nodes in levels + const nodePositions: Map<string, { x: number; y: number }> = new Map(); + const visited = new Set<string>(); + const queue: { id: string; level: number; index: number }[] = []; + + const horizontalSpacing = 350; + const verticalSpacing = 150; + + // Initialize with root nodes + rootNodes.forEach((node, index) => { + queue.push({ id: node.id, level: 0, index }); + visited.add(node.id); + }); + + // Track nodes per level for vertical positioning + const nodesPerLevel: Map<number, number> = new Map(); + + while (queue.length > 0) { + const { id, level, index } = queue.shift()!; + + // Count nodes at this level + const currentCount = nodesPerLevel.get(level) || 0; + nodesPerLevel.set(level, currentCount + 1); + + // Calculate position + const x = 100 + level * horizontalSpacing; + const y = 100 + currentCount * verticalSpacing; + nodePositions.set(id, { x, y }); + + // Find child nodes + const outgoingEdges = edges.filter(e => e.source === id); + outgoingEdges.forEach((edge, i) => { + if (!visited.has(edge.target)) { + visited.add(edge.target); + queue.push({ id: edge.target, level: level + 1, index: i }); + } + }); + } + + // Handle orphan nodes (not connected to anything) + let orphanY = 100; + nodes.forEach(node => { + if (!nodePositions.has(node.id)) { + nodePositions.set(node.id, { x: 100, y: orphanY }); + orphanY += verticalSpacing; + } + }); + + // Apply positions + set({ + nodes: nodes.map(node => ({ + ...node, + position: nodePositions.get(node.id) || node.position + })) + }); + }, onNodesChange: (changes: NodeChange[]) => { + // Check if any nodes are being removed + const hasRemovals = changes.some(c => c.type === 'remove'); + set({ nodes: applyNodeChanges(changes, get().nodes) as LLMNode[], }); + + // If nodes were removed, also clean up related edges and propagate traces + if (hasRemovals) { + const removedIds = changes.filter(c => c.type === 'remove').map(c => c.id); + set({ + edges: get().edges.filter(e => !removedIds.includes(e.source) && !removedIds.includes(e.target)) + }); + get().propagateTraces(); + } }, onEdgesChange: (changes: EdgeChange[]) => { set({ @@ -103,52 +413,448 @@ const useFlowStore = create<FlowState>((set, get) => ({ get().propagateTraces(); }, onConnect: (connection: Connection) => { - const { nodes } = get(); + const { nodes, edges } = get(); + const sourceNode = nodes.find(n => n.id === connection.source); + if (!sourceNode) return; - // Check if connecting from "new-trace" handle - if (connection.sourceHandle === 'new-trace') { - // Logic: Create a new Forked Trace on the source node - const sourceNode = nodes.find(n => n.id === connection.source); - if (sourceNode) { - // Generate the content for this new trace (it's essentially the Self Trace of this node) - const myResponseMsg: Message[] = []; - if (sourceNode.data.userPrompt) myResponseMsg.push({ id: `${sourceNode.id}-u`, role: 'user', content: sourceNode.data.userPrompt }); - if (sourceNode.data.response) myResponseMsg.push({ id: `${sourceNode.id}-a`, role: 'assistant', content: sourceNode.data.response }); - - const newForkId = `trace-${sourceNode.id}-fork-${Date.now()}`; - const newForkTrace: Trace = { - id: newForkId, - sourceNodeId: sourceNode.id, - color: getStableColor(newForkId), // Unique color for this fork - messages: [...myResponseMsg] - }; - - // Update Source Node to include this fork - get().updateNodeData(sourceNode.id, { - forkedTraces: [...(sourceNode.data.forkedTraces || []), newForkTrace] + // Handle prepend connections - only allow from 'new-trace' handle + // Prepend makes the source node become the NEW HEAD of an existing trace + if (connection.targetHandle?.startsWith('prepend-')) { + // Prepend connections MUST come from 'new-trace' handle + if (connection.sourceHandle !== 'new-trace') { + console.warn('Prepend connections only allowed from new-trace handle'); + return; + } + + const targetNode = nodes.find(n => n.id === connection.target); + if (!targetNode) return; + + // Get the trace ID from the prepend handle - this is the trace we're joining + const traceId = connection.targetHandle.replace('prepend-', '').replace('inherited-', ''); + const regularTrace = targetNode.data.outgoingTraces?.find(t => t.id === traceId); + const mergedTrace = targetNode.data.mergedTraces?.find(m => m.id === traceId); + const traceColor = regularTrace?.color || (mergedTrace?.colors?.[0]) || getStableColor(traceId); + + // Instead of creating a new trace, we JOIN the existing trace + // The source node (C) becomes the new head of the trace + // We use the SAME trace ID so it's truly the same trace + + // Add this trace as a "forked trace" on the source node so it shows up as an output handle + // But use the ORIGINAL trace ID (not a new prepend-xxx ID) + const inheritedTrace: Trace = { + id: traceId, // Use the SAME trace ID! + sourceNodeId: sourceNode.id, // C is now the source/head + color: traceColor, + messages: [] // Will be populated by propagateTraces + }; + + // Add to source node's forkedTraces + get().updateNodeData(sourceNode.id, { + forkedTraces: [...(sourceNode.data.forkedTraces || []), inheritedTrace] + }); + + // Update target node's forkedTrace to mark it as "prepended" + // by setting sourceNodeId to the new head (source node) + const targetForked = targetNode.data.forkedTraces || []; + const updatedForked = targetForked.map(t => + t.id === traceId ? { ...t, sourceNodeId: sourceNode.id } : t + ); + if (JSON.stringify(updatedForked) !== JSON.stringify(targetForked)) { + get().updateNodeData(targetNode.id, { + forkedTraces: updatedForked + }); + } + + // Create the edge using the SAME trace ID + // This connects C's output to A's prepend input + set({ + edges: addEdge({ + ...connection, + sourceHandle: `trace-${traceId}`, // Same trace ID! + style: { stroke: traceColor, strokeWidth: 2 } + }, get().edges), + }); + + setTimeout(() => get().propagateTraces(), 0); + return; + } + + // Helper to trace back the path of a trace by following edges upstream + const duplicateTracePath = ( + traceId: string, + forkAtNodeId: string, + traceOwnerNodeId?: string, + pendingEdges: Edge[] = [] + ): { newTraceId: string, newEdges: Edge[], firstNodeId: string } | null => { + // Trace back from forkAtNodeId to find the origin of this trace + // We follow incoming edges that match the trace pattern + + const pathNodes: string[] = [forkAtNodeId]; + const pathEdges: Edge[] = []; + let currentNodeId = forkAtNodeId; + + // Trace backwards through incoming edges + while (true) { + // Find incoming edge to current node that carries THIS trace ID + const incomingEdge = edges.find(e => + e.target === currentNodeId && + e.sourceHandle === `trace-${traceId}` + ); + + if (!incomingEdge) break; // Reached the start of the trace + + pathNodes.unshift(incomingEdge.source); + pathEdges.unshift(incomingEdge); + currentNodeId = incomingEdge.source; + } + + // If path only has one node, no upstream to duplicate + if (pathNodes.length <= 1) return null; + + const firstNodeId = pathNodes[0]; + const firstNode = nodes.find(n => n.id === firstNodeId); + if (!firstNode) return null; + + // Create a new trace ID for the duplicated path (guarantee uniqueness even within the same ms) + const uniq = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const newTraceId = `fork-${firstNodeId}-${uniq}`; + const newTraceColor = getStableColor(newTraceId); + + // Create new edges for the entire path + const newEdges: Edge[] = []; + + // Track which input handles we're creating for new edges + const newInputHandles: Map<string, number> = new Map(); + + for (let i = 0; i < pathEdges.length; i++) { + const originalEdge = pathEdges[i]; + const fromNodeId = pathNodes[i]; + const toNodeId = pathNodes[i + 1]; + + // Find the next available input handle for the target node + // Count existing edges to this node + any new edges we're creating + const existingEdgesToTarget = + edges.filter(e => e.target === toNodeId).length + + pendingEdges.filter(e => e.target === toNodeId).length; + const newEdgesToTarget = newInputHandles.get(toNodeId) || 0; + const nextInputIndex = existingEdgesToTarget + newEdgesToTarget; + newInputHandles.set(toNodeId, newEdgesToTarget + 1); + + newEdges.push({ + id: `edge-fork-${uniq}-${i}`, + source: fromNodeId, + target: toNodeId, + sourceHandle: `trace-${newTraceId}`, + targetHandle: `input-${nextInputIndex}`, + style: { stroke: newTraceColor, strokeWidth: 2 } + }); + } + + // Find the messages up to the fork point + const traceOwnerNode = traceOwnerNodeId + ? nodes.find(n => n.id === traceOwnerNodeId) + : sourceNode; + if (!traceOwnerNode) return null; + + const originalTrace = traceOwnerNode.data.outgoingTraces?.find(t => t.id === traceId); + const messagesUpToFork = originalTrace?.messages || []; + + // Add the new trace as a forked trace on the first node + const newForkTrace: Trace = { + id: newTraceId, + sourceNodeId: firstNodeId, + color: newTraceColor, + messages: [...messagesUpToFork] + }; + + get().updateNodeData(firstNodeId, { + forkedTraces: [...(firstNode.data.forkedTraces || []), newForkTrace] + }); + + return { newTraceId, newEdges, firstNodeId }; + }; + + // Helper to duplicate the downstream segment of a trace from a start node to an end node + const duplicateDownstreamSegment = ( + originalTraceId: string, + startNodeId: string, + endNodeId: string, + newTraceId: string, + newTraceColor: string, + newTraceColors: string[] + ): Edge[] | null => { + const segmentEdges: Edge[] = []; + let currentNodeId = startNodeId; + const visitedEdgeIds = new Set<string>(); + + while (currentNodeId !== endNodeId) { + const nextEdge = edges.find( + (e) => e.source === currentNodeId && e.sourceHandle === `trace-${originalTraceId}` + ); + + if (!nextEdge || visitedEdgeIds.has(nextEdge.id)) { + return null; + } + + segmentEdges.push(nextEdge); + visitedEdgeIds.add(nextEdge.id); + currentNodeId = nextEdge.target; + } + + const newEdges: Edge[] = []; + const newInputCounts: Map<string, number> = new Map(); + const segmentTimestamp = Date.now(); + + segmentEdges.forEach((edge, index) => { + const targetNodeId = edge.target; + const existingEdgesToTarget = edges.filter((e) => e.target === targetNodeId).length; + const additionalEdges = newInputCounts.get(targetNodeId) || 0; + const nextInputIndex = existingEdgesToTarget + additionalEdges; + newInputCounts.set(targetNodeId, additionalEdges + 1); + + newEdges.push({ + id: `edge-merged-seg-${segmentTimestamp}-${index}`, + source: edge.source, + target: edge.target, + sourceHandle: `trace-${newTraceId}`, + targetHandle: `input-${nextInputIndex}`, + type: 'merged', + style: { stroke: newTraceColor, strokeWidth: 2 }, + data: { isMerged: true, colors: newTraceColors } + }); + }); + + return newEdges; + }; + + // Helper to duplicate a merged trace by cloning its parent traces and creating a new merged branch + const duplicateMergedTraceBranch = ( + mergedTrace: Trace, + forkAtNodeId: string + ): { newTraceId: string; newEdges: Edge[]; color: string } | null => { + const mergeNodeId = mergedTrace.sourceNodeId; + const mergeNode = nodes.find((n) => n.id === mergeNodeId); + if (!mergeNode) return null; + + const mergedDef = + mergeNode.data.mergedTraces?.find((m: MergedTrace) => m.id === mergedTrace.id) || null; + const parentTraceIds = mergedTrace.sourceTraceIds || mergedDef?.sourceTraceIds || []; + if (parentTraceIds.length === 0) return null; + + let accumulatedEdges: Edge[] = []; + const newParentTraceIds: string[] = []; + const parentOverrides: Trace[] = []; + + for (const parentId of parentTraceIds) { + const originalParentTrace = mergeNode.data.traces?.find((t: Trace) => t.id === parentId); + + if (originalParentTrace?.isMerged && originalParentTrace.sourceTraceIds?.length) { + const nestedDuplicate = duplicateMergedTraceBranch(originalParentTrace, mergeNodeId); + if (!nestedDuplicate) { + return null; + } + accumulatedEdges = accumulatedEdges.concat(nestedDuplicate.newEdges); + newParentTraceIds.push(nestedDuplicate.newTraceId); + + parentOverrides.push({ + ...originalParentTrace, + id: nestedDuplicate.newTraceId, }); - - // Redirect connection to the new handle - // Note: We must wait for propagateTraces to render the new handle? - // ReactFlow might complain if handle doesn't exist yet. - // But since we updateNodeData synchronously (mostly), it might work. - // Let's use the new ID for the connection. - + + continue; + } + + const duplicateResult = duplicateTracePath(parentId, mergeNodeId, mergeNodeId, accumulatedEdges); + if (!duplicateResult) { + return null; + } + accumulatedEdges = accumulatedEdges.concat(duplicateResult.newEdges); + newParentTraceIds.push(duplicateResult.newTraceId); + + if (originalParentTrace) { + parentOverrides.push({ + ...originalParentTrace, + id: duplicateResult.newTraceId, + }); + } + } + + const strategy = mergedDef?.strategy || 'trace_order'; + const uniqMerged = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const newMergedId = `merged-${mergeNodeId}-${uniqMerged}`; + const newColors = + parentOverrides.length > 0 + ? parentOverrides.map((t) => t.color).filter((c): c is string => Boolean(c)) + : mergedTrace.mergedColors ?? []; + + const overrideTraces = parentOverrides.length > 0 ? parentOverrides : undefined; + const mergedMessages = get().computeMergedMessages( + mergeNodeId, + newParentTraceIds, + strategy, + overrideTraces + ); + + const newMergedDefinition: MergedTrace = { + id: newMergedId, + sourceNodeId: mergeNodeId, + sourceTraceIds: newParentTraceIds, + strategy, + colors: newColors.length ? newColors : [mergedTrace.color], + messages: mergedMessages, + }; + + const existingMerged = mergeNode.data.mergedTraces || []; + get().updateNodeData(mergeNodeId, { + mergedTraces: [...existingMerged, newMergedDefinition], + }); + + const newMergedColor = newColors[0] || mergedTrace.color || getStableColor(newMergedId); + const downstreamEdges = duplicateDownstreamSegment( + mergedTrace.id, + mergeNodeId, + forkAtNodeId, + newMergedId, + newMergedColor, + newColors.length ? newColors : [mergedTrace.color] + ); + if (!downstreamEdges) return null; + + accumulatedEdges = accumulatedEdges.concat(downstreamEdges); + + return { + newTraceId: newMergedId, + newEdges: accumulatedEdges, + color: newMergedColor, + }; + }; + + // Helper to create a simple forked trace (for new-trace handle or first connection) + const createSimpleForkTrace = () => { + let originalTraceMessages: Message[] = []; + + if (connection.sourceHandle?.startsWith('trace-')) { + const originalTraceId = connection.sourceHandle.replace('trace-', ''); + const originalTrace = sourceNode.data.outgoingTraces?.find(t => t.id === originalTraceId); + if (originalTrace) { + originalTraceMessages = [...originalTrace.messages]; + } + } + + if (originalTraceMessages.length === 0) { + if (sourceNode.data.userPrompt) { + originalTraceMessages.push({ id: `${sourceNode.id}-u`, role: 'user', content: sourceNode.data.userPrompt }); + } + if (sourceNode.data.response) { + originalTraceMessages.push({ id: `${sourceNode.id}-a`, role: 'assistant', content: sourceNode.data.response }); + } + } + + const newForkId = `fork-${sourceNode.id}-${Date.now()}`; + const newForkTrace: Trace = { + id: newForkId, + sourceNodeId: sourceNode.id, + color: getStableColor(newForkId), + messages: originalTraceMessages + }; + + get().updateNodeData(sourceNode.id, { + forkedTraces: [...(sourceNode.data.forkedTraces || []), newForkTrace] + }); + + return newForkTrace; + }; + + // Check if connecting from "new-trace" handle - always create simple fork + if (connection.sourceHandle === 'new-trace') { + const newForkTrace = createSimpleForkTrace(); + + set({ + edges: addEdge({ + ...connection, + sourceHandle: `trace-${newForkTrace.id}`, + style: { stroke: newForkTrace.color, strokeWidth: 2 } + }, get().edges), + }); + + setTimeout(() => get().propagateTraces(), 0); + return; + } + + // Check if this trace handle already has a downstream connection + const existingEdgeFromHandle = edges.find( + e => e.source === connection.source && e.sourceHandle === connection.sourceHandle + ); + + if (existingEdgeFromHandle && connection.sourceHandle?.startsWith('trace-')) { + const originalTraceId = connection.sourceHandle.replace('trace-', ''); + const traceMeta = sourceNode.data.outgoingTraces?.find((t: Trace) => t.id === originalTraceId); + + if (traceMeta?.isMerged && traceMeta.sourceTraceIds && traceMeta.sourceTraceIds.length > 0) { + const mergedDuplicate = duplicateMergedTraceBranch(traceMeta, connection.source!); + if (mergedDuplicate) { set({ - edges: addEdge({ - ...connection, - sourceHandle: `trace-${newForkId}`, // Redirect! - style: { stroke: newForkTrace.color, strokeWidth: 2 } - }, get().edges), + edges: [ + ...get().edges, + ...mergedDuplicate.newEdges, + { + id: `edge-${connection.source}-${connection.target}-${Date.now()}`, + source: connection.source!, + target: connection.target!, + sourceHandle: `trace-${mergedDuplicate.newTraceId}`, + targetHandle: connection.targetHandle, + type: 'merged', + style: { stroke: mergedDuplicate.color, strokeWidth: 2 }, + data: { isMerged: true, colors: traceMeta.mergedColors || [] } + } as Edge + ], }); - // Trigger propagation to update downstream setTimeout(() => get().propagateTraces(), 0); return; - } + } + } + + const duplicateResult = duplicateTracePath(originalTraceId, connection.source!); + + if (duplicateResult) { + const { newTraceId, newEdges } = duplicateResult; + const newTraceColor = getStableColor(newTraceId); + + set({ + edges: [ + ...get().edges, + ...newEdges, + { + id: `edge-${connection.source}-${connection.target}-${Date.now()}`, + source: connection.source!, + target: connection.target!, + sourceHandle: `trace-${newTraceId}`, + targetHandle: connection.targetHandle, + style: { stroke: newTraceColor, strokeWidth: 2 } + } as Edge + ], + }); + + setTimeout(() => get().propagateTraces(), 0); + return; + } else { + const newForkTrace = createSimpleForkTrace(); + + set({ + edges: addEdge({ + ...connection, + sourceHandle: `trace-${newForkTrace.id}`, + style: { stroke: newForkTrace.color, strokeWidth: 2 } + }, get().edges), + }); + + setTimeout(() => get().propagateTraces(), 0); + return; + } } - // Normal connection + // Normal connection - no existing edge from this handle set({ edges: addEdge({ ...connection, @@ -186,16 +892,52 @@ const useFlowStore = create<FlowState>((set, get) => ({ const node = get().nodes.find(n => n.id === nodeId); if (!node) return []; - // The traces stored in node.data.traces are the INCOMING traces. - // If we select one, we want its history. + const activeIds = node.data.activeTraceIds || []; + if (activeIds.length === 0) return []; - const activeTraces = node.data.traces.filter(t => - node.data.activeTraceIds?.includes(t.id) - ); + // Collect all traces by ID to avoid duplicates + const tracesById = new Map<string, Trace>(); + // Add incoming traces + (node.data.traces || []).forEach((t: Trace) => { + if (activeIds.includes(t.id)) { + tracesById.set(t.id, t); + } + }); + + // Add outgoing traces (only if not already in incoming) + (node.data.outgoingTraces || []).forEach((t: Trace) => { + if (activeIds.includes(t.id) && !tracesById.has(t.id)) { + tracesById.set(t.id, t); + } + }); + + // Collect messages from selected traces const contextMessages: Message[] = []; - activeTraces.forEach(t => { - contextMessages.push(...t.messages); + const nodePrefix = `${nodeId}-`; + + tracesById.forEach((t: Trace) => { + // For traces originated by this node, filter out this node's own messages + const isOriginated = t.id === `trace-${nodeId}` || + t.id.startsWith('fork-') || + (t.id.startsWith('prepend-') && t.id.includes(`-from-${nodeId}`)); + + if (isOriginated) { + // Only include prepended upstream messages + const prependedMessages = t.messages.filter(m => !m.id?.startsWith(nodePrefix)); + contextMessages.push(...prependedMessages); + } else { + // Include all messages for incoming traces + contextMessages.push(...t.messages); + } + }); + + // Check merged traces + const activeMerged = (node.data.mergedTraces || []).filter((m: MergedTrace) => + activeIds.includes(m.id) + ); + activeMerged.forEach((m: MergedTrace) => { + contextMessages.push(...m.messages); }); return contextMessages; @@ -274,6 +1016,851 @@ const useFlowStore = create<FlowState>((set, get) => ({ get().propagateTraces(); }, + deleteTrace: (startEdgeId: string) => { + const { edges, nodes } = get(); + // Delete edges along the trace AND orphaned nodes (nodes with no remaining connections) + const edgesToDelete = new Set<string>(); + const nodesInTrace = new Set<string>(); + + // Helper to traverse downstream EDGES based on Trace Dependency + const traverse = (currentEdge: Edge) => { + if (edgesToDelete.has(currentEdge.id)) return; + edgesToDelete.add(currentEdge.id); + + const targetNodeId = currentEdge.target; + nodesInTrace.add(targetNodeId); + + // Identify the trace ID carried by this edge + const traceId = currentEdge.sourceHandle?.replace('trace-', ''); + if (!traceId) return; + + // Look for outgoing edges from the target node that carry the EVOLUTION of this trace. + const expectedNextTraceId = `${traceId}_${targetNodeId}`; + + const outgoing = edges.filter(e => e.source === targetNodeId); + outgoing.forEach(nextEdge => { + if (nextEdge.sourceHandle === `trace-${expectedNextTraceId}`) { + traverse(nextEdge); + } + }); + }; + + // Also traverse backwards to find upstream nodes + const traverseBackward = (currentEdge: Edge) => { + if (edgesToDelete.has(currentEdge.id)) return; + edgesToDelete.add(currentEdge.id); + + const sourceNodeId = currentEdge.source; + nodesInTrace.add(sourceNodeId); + + // Find the incoming edge to the source node that is part of the same trace + const traceId = currentEdge.sourceHandle?.replace('trace-', ''); + if (!traceId) return; + + // Find the parent trace ID by removing the last _nodeId suffix + const lastUnderscore = traceId.lastIndexOf('_'); + if (lastUnderscore > 0) { + const parentTraceId = traceId.substring(0, lastUnderscore); + const incoming = edges.filter(e => e.target === sourceNodeId); + incoming.forEach(prevEdge => { + if (prevEdge.sourceHandle === `trace-${parentTraceId}`) { + traverseBackward(prevEdge); + } + }); + } + }; + + const startEdge = edges.find(e => e.id === startEdgeId); + if (startEdge) { + // Traverse forward + traverse(startEdge); + // Traverse backward + traverseBackward(startEdge); + } + + // Filter remaining edges after deletion + const remainingEdges = edges.filter(e => !edgesToDelete.has(e.id)); + + // Find nodes that become orphaned (no connections at all after edge deletion) + const nodesToDelete = new Set<string>(); + nodesInTrace.forEach(nodeId => { + const hasRemainingEdges = remainingEdges.some( + e => e.source === nodeId || e.target === nodeId + ); + if (!hasRemainingEdges) { + nodesToDelete.add(nodeId); + } + }); + + set({ + nodes: nodes.filter(n => !nodesToDelete.has(n.id)), + edges: remainingEdges + }); + + get().propagateTraces(); + }, + + toggleNodeDisabled: (nodeId: string) => { + const node = get().nodes.find(n => n.id === nodeId); + if (node) { + const newDisabled = !node.data.disabled; + // Update node data AND draggable property + set(state => ({ + nodes: state.nodes.map(n => { + if (n.id === nodeId) { + return { + ...n, + draggable: !newDisabled, // Disable dragging when node is disabled + selectable: !newDisabled, // Disable selection when node is disabled + data: { ...n.data, disabled: newDisabled } + }; + } + return n; + }) + })); + // Update edge styles to reflect disabled state + setTimeout(() => get().updateEdgeStyles(), 0); + } + }, + + archiveNode: (nodeId: string) => { + const node = get().nodes.find(n => n.id === nodeId); + if (!node) return; + + const archived: ArchivedNode = { + id: `archive_${Date.now()}`, + label: node.data.label, + model: node.data.model, + systemPrompt: node.data.systemPrompt, + temperature: node.data.temperature, + reasoningEffort: node.data.reasoningEffort || 'medium', + userPrompt: node.data.userPrompt, + response: node.data.response, + enableGoogleSearch: node.data.enableGoogleSearch, + mergeStrategy: node.data.mergeStrategy + }; + + set(state => ({ + archivedNodes: [...state.archivedNodes, archived] + })); + setTimeout(() => get().saveArchivedNodes().catch(() => {}), 0); + }, + + removeFromArchive: (archiveId: string) => { + set(state => ({ + archivedNodes: state.archivedNodes.filter(a => a.id !== archiveId) + })); + setTimeout(() => get().saveArchivedNodes().catch(() => {}), 0); + }, + + createNodeFromArchive: (archiveId: string, position: { x: number; y: number }) => { + const archived = get().archivedNodes.find(a => a.id === archiveId); + if (!archived) return; + + const newNode: LLMNode = { + id: `node_${Date.now()}`, + type: 'llmNode', + position, + data: { + label: archived.label, + model: archived.model, + temperature: archived.temperature, + systemPrompt: archived.systemPrompt, + userPrompt: archived.userPrompt || '', + reasoningEffort: archived.reasoningEffort, + enableGoogleSearch: archived.enableGoogleSearch, + mergeStrategy: archived.mergeStrategy || 'smart', + traces: [], + outgoingTraces: [], + forkedTraces: [], + mergedTraces: [], + activeTraceIds: [], + response: archived.response || '', + status: 'idle', + inputs: 1 + } + }; + + get().addNode(newNode); + }, + + toggleTraceDisabled: (edgeId: string) => { + const { edges, nodes } = get(); + const edge = edges.find(e => e.id === edgeId); + if (!edge) return; + + // Find all nodes connected through this trace (BIDIRECTIONAL) + const nodesInTrace = new Set<string>(); + const visitedEdges = new Set<string>(); + + // Traverse downstream (source -> target direction) + const traverseDownstream = (currentNodeId: string) => { + nodesInTrace.add(currentNodeId); + + const outgoing = edges.filter(e => e.source === currentNodeId); + outgoing.forEach(nextEdge => { + if (visitedEdges.has(nextEdge.id)) return; + visitedEdges.add(nextEdge.id); + traverseDownstream(nextEdge.target); + }); + }; + + // Traverse upstream (target -> source direction) + const traverseUpstream = (currentNodeId: string) => { + nodesInTrace.add(currentNodeId); + + const incoming = edges.filter(e => e.target === currentNodeId); + incoming.forEach(prevEdge => { + if (visitedEdges.has(prevEdge.id)) return; + visitedEdges.add(prevEdge.id); + traverseUpstream(prevEdge.source); + }); + }; + + // Start bidirectional traversal from clicked edge + visitedEdges.add(edge.id); + + // Go upstream from source (including source itself) + traverseUpstream(edge.source); + + // Go downstream from target (including target itself) + traverseDownstream(edge.target); + + // Check if any node in this trace is disabled + const anyDisabled = Array.from(nodesInTrace).some( + nodeId => nodes.find(n => n.id === nodeId)?.data.disabled + ); + + // Toggle: if any disabled -> enable all, else disable all + const newDisabledState = !anyDisabled; + + set(state => ({ + nodes: state.nodes.map(node => { + if (nodesInTrace.has(node.id)) { + return { + ...node, + draggable: !newDisabledState, + selectable: !newDisabledState, + data: { ...node.data, disabled: newDisabledState } + }; + } + return node; + }) + })); + + // Update edge styles + get().updateEdgeStyles(); + }, + + updateEdgeStyles: () => { + const { nodes, edges } = get(); + + const updatedEdges = edges.map(edge => { + const sourceNode = nodes.find(n => n.id === edge.source); + const targetNode = nodes.find(n => n.id === edge.target); + + const isDisabled = sourceNode?.data.disabled || targetNode?.data.disabled; + + return { + ...edge, + style: { + ...edge.style, + opacity: isDisabled ? 0.3 : 1, + strokeDasharray: isDisabled ? '5,5' : undefined + } + }; + }); + + set({ edges: updatedEdges }); + }, + + // Check if all nodes in trace path have complete Q&A + isTraceComplete: (trace: Trace) => { + // A trace is complete if all nodes in the path have complete Q&A pairs + const messages = trace.messages; + + if (messages.length === 0) { + // Empty trace - check if the source node has content + const { nodes } = get(); + const sourceNode = nodes.find(n => n.id === trace.sourceNodeId); + if (sourceNode) { + return !!sourceNode.data.userPrompt && !!sourceNode.data.response; + } + return true; // No source node, assume complete + } + + // Extract node IDs from message IDs (format: nodeId-user or nodeId-assistant) + // Group messages by node and check each node has both user and assistant + const nodeMessages = new Map<string, { hasUser: boolean; hasAssistant: boolean }>(); + + for (const msg of messages) { + if (!msg.id) continue; + + // Parse nodeId from message ID (format: nodeId-user or nodeId-assistant) + const parts = msg.id.split('-'); + if (parts.length < 2) continue; + + // The nodeId is everything except the last part (user/assistant) + const lastPart = parts[parts.length - 1]; + const nodeId = lastPart === 'user' || lastPart === 'assistant' + ? parts.slice(0, -1).join('-') + : msg.id; // Fallback: use whole ID if format doesn't match + + if (!nodeMessages.has(nodeId)) { + nodeMessages.set(nodeId, { hasUser: false, hasAssistant: false }); + } + + const nodeData = nodeMessages.get(nodeId)!; + if (msg.role === 'user') nodeData.hasUser = true; + if (msg.role === 'assistant') nodeData.hasAssistant = true; + } + + // Check that ALL nodes in the trace have both user and assistant messages + for (const [nodeId, data] of nodeMessages) { + if (!data.hasUser || !data.hasAssistant) { + return false; // This node is incomplete + } + } + + // Must have at least one complete node + return nodeMessages.size > 0; + }, + + // Get all node IDs in trace path + getTraceNodeIds: (trace: Trace) => { + const traceId = trace.id; + const parts = traceId.replace('trace-', '').split('_'); + return parts.filter(p => p.startsWith('node') || get().nodes.some(n => n.id === p)); + }, + + // Create a new node for quick chat, with proper connection + createQuickChatNode: ( + fromNodeId: string, + trace: Trace | null, + userPrompt: string, + response: string, + model: string, + config: Partial<NodeData> + ) => { + const { nodes, edges, addNode, updateNodeData } = get(); + const fromNode = nodes.find(n => n.id === fromNodeId); + + if (!fromNode) return ''; + + // Check if current node is empty (no response) -> overwrite it + const isCurrentNodeEmpty = !fromNode.data.response; + + if (isCurrentNodeEmpty) { + // Overwrite current node + updateNodeData(fromNodeId, { + userPrompt, + response, + model, + status: 'success', + querySentAt: Date.now(), + responseReceivedAt: Date.now(), + ...config + }); + return fromNodeId; + } + + // Create new node to the right + // Use findNonOverlappingPosition to avoid collision, starting from the ideal position + const idealX = fromNode.position.x + 300; + const idealY = fromNode.position.y; + + // Check if ideal position overlaps + const { findNonOverlappingPosition } = get(); + const newPos = findNonOverlappingPosition(idealX, idealY); + + const newNodeId = `node_${Date.now()}`; + const newNode: LLMNode = { + id: newNodeId, + type: 'llmNode', + position: newPos, + data: { + label: 'Quick Chat', + model, + temperature: config.temperature || 0.7, + systemPrompt: '', + userPrompt, + mergeStrategy: 'smart', + reasoningEffort: config.reasoningEffort || 'medium', + enableGoogleSearch: config.enableGoogleSearch, + traces: [], + outgoingTraces: [], + forkedTraces: [], + mergedTraces: [], + activeTraceIds: [], + response, + status: 'success', + inputs: 1, + querySentAt: Date.now(), + responseReceivedAt: Date.now(), + ...config + } + }; + + addNode(newNode); + + // Connect from the source node using new-trace handle (creates a fork) + setTimeout(() => { + const store = useFlowStore.getState(); + store.onConnect({ + source: fromNodeId, + sourceHandle: 'new-trace', + target: newNodeId, + targetHandle: 'input-0' + }); + }, 50); + + return newNodeId; + }, + + // -------- Blueprint serialization / persistence -------- + setCurrentBlueprintPath: (path?: string) => { + set({ currentBlueprintPath: path }); + }, + + serializeBlueprint: (viewport?: ViewportState): BlueprintDocument => { + return { + version: 1, + nodes: get().nodes, + edges: get().edges, + viewport: viewport || get().lastViewport, + theme: get().theme, + }; + }, + + loadBlueprint: (doc: BlueprintDocument): ViewportState | undefined => { + set({ + nodes: (doc.nodes || []) as LLMNode[], + edges: (doc.edges || []) as Edge[], + theme: doc.theme || get().theme, + selectedNodeId: null, + lastViewport: doc.viewport || get().lastViewport, + }); + // Recompute traces after loading + setTimeout(() => get().propagateTraces(), 0); + return doc.viewport; + }, + + saveBlueprintFile: async (path: string, viewport?: ViewportState) => { + const payload = get().serializeBlueprint(viewport); + await jsonFetch(`${API_BASE}/api/projects/save_blueprint`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user: DEFAULT_USER, + path, + content: payload, + }), + }); + set({ currentBlueprintPath: path, lastViewport: payload.viewport }); + await get().refreshProjectTree(); + }, + + readBlueprintFile: async (path: string): Promise<BlueprintDocument> => { + const res = await jsonFetch<{ content: BlueprintDocument }>( + `${API_BASE}/api/projects/file?user=${encodeURIComponent(DEFAULT_USER)}&path=${encodeURIComponent(path)}` + ); + return validateBlueprint(res.content); + }, + + refreshProjectTree: async () => { + const tree = await jsonFetch<FSItem[]>( + `${API_BASE}/api/projects/tree?user=${encodeURIComponent(DEFAULT_USER)}` + ); + set({ projectTree: tree }); + return tree; + }, + + createProjectFolder: async (path: string) => { + await jsonFetch(`${API_BASE}/api/projects/create_folder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: DEFAULT_USER, path }), + }); + await get().refreshProjectTree(); + }, + + renameProjectItem: async (path: string, newName?: string, newPath?: string) => { + if (!newName && !newPath) { + throw new Error('newName or newPath is required'); + } + await jsonFetch(`${API_BASE}/api/projects/rename`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: DEFAULT_USER, path, new_name: newName, new_path: newPath }), + }); + await get().refreshProjectTree(); + }, + + deleteProjectItem: async (path: string, isFolder = false) => { + await jsonFetch(`${API_BASE}/api/projects/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user: DEFAULT_USER, path, is_folder: isFolder }), + }); + await get().refreshProjectTree(); + }, + + saveCurrentBlueprint: async (path?: string, viewport?: ViewportState) => { + const targetPath = path || get().currentBlueprintPath; + if (!targetPath) { + throw new Error('No blueprint path. Please provide a file name.'); + } + set({ saveStatus: 'saving' }); + try { + await get().saveBlueprintFile(targetPath, viewport); + set({ saveStatus: 'saved', currentBlueprintPath: targetPath }); + } catch (e) { + console.error(e); + set({ saveStatus: 'error' }); + throw e; + } + }, + + clearBlueprint: () => { + set({ + nodes: [], + edges: [], + selectedNodeId: null, + currentBlueprintPath: undefined, + lastViewport: undefined, + saveStatus: 'idle', + }); + }, + + loadArchivedNodes: async () => { + const res = await jsonFetch<{ archived: ArchivedNode[] }>( + `${API_BASE}/api/projects/archived?user=${encodeURIComponent(DEFAULT_USER)}` + ); + set({ archivedNodes: res.archived || [] }); + }, + + saveArchivedNodes: async () => { + const payload = { user: DEFAULT_USER, archived: get().archivedNodes }; + await jsonFetch(`${API_BASE}/api/projects/archived`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + }, + + // Files management + refreshFiles: async () => { + const res = await jsonFetch<{ files: FileMeta[] }>( + `${API_BASE}/api/files?user=${encodeURIComponent(DEFAULT_USER)}` + ); + set({ files: res.files || [] }); + }, + + uploadFile: async (file: File) => { + const form = new FormData(); + form.append('file', file); + const res = await fetch(`${API_BASE}/api/files/upload?user=${encodeURIComponent(DEFAULT_USER)}`, { + method: 'POST', + body: form, + }); + if (!res.ok) { + throw new Error(await res.text()); + } + const data = await res.json(); + if (!data.file) { + throw new Error('Upload succeeded but no file info returned'); + } + await get().refreshFiles(); + return data.file as FileMeta; + }, + + deleteFile: async (fileId: string) => { + const res = await fetch(`${API_BASE}/api/files/delete?user=${encodeURIComponent(DEFAULT_USER)}&file_id=${encodeURIComponent(fileId)}`, { + method: 'POST', + }); + if (!res.ok) { + throw new Error(await res.text()); + } + await get().refreshFiles(); + }, + + // -------------------------------------------------------- + + // Compute merged messages based on strategy + // Optional tracesOverride parameter to use latest traces during propagation + computeMergedMessages: (nodeId: string, sourceTraceIds: string[], strategy: MergeStrategy, tracesOverride?: Trace[]): Message[] => { + const { nodes } = get(); + const node = nodes.find(n => n.id === nodeId); + if (!node) return []; + + // Use override traces if provided (for propagation), otherwise use node's stored traces + const availableTraces = tracesOverride || node.data.traces || []; + + // Get the source traces + const sourceTraces = sourceTraceIds + .map(id => availableTraces.find((t: Trace) => t.id === id)) + .filter((t): t is Trace => t !== undefined); + + if (sourceTraces.length === 0) return []; + + // Helper to add source trace info to a message + const tagMessage = (msg: Message, trace: Trace): Message => ({ + ...msg, + sourceTraceId: trace.id, + sourceTraceColor: trace.color + }); + + // Helper to get timestamp for a message based on node + const getMessageTimestamp = (msg: Message, type: 'query' | 'response'): number => { + // Extract node ID from message ID (format: nodeId-user or nodeId-assistant) + const msgNodeId = msg.id?.split('-')[0]; + if (!msgNodeId) return 0; + + const msgNode = nodes.find(n => n.id === msgNodeId); + if (!msgNode) return 0; + + if (type === 'query') { + return msgNode.data.querySentAt || 0; + } else { + return msgNode.data.responseReceivedAt || 0; + } + }; + + // Helper to get all messages with their timestamps and trace info + const getAllMessagesWithTime = () => { + const allMessages: { msg: Message; queryTime: number; responseTime: number; trace: Trace }[] = []; + + sourceTraces.forEach((trace) => { + trace.messages.forEach(msg => { + allMessages.push({ + msg: tagMessage(msg, trace), + queryTime: getMessageTimestamp(msg, 'query'), + responseTime: getMessageTimestamp(msg, 'response'), + trace + }); + }); + }); + + return allMessages; + }; + + switch (strategy) { + case 'query_time': { + // Sort by query time, keeping Q-A pairs together + const pairs: { user: Message | null; assistant: Message | null; time: number; trace: Trace }[] = []; + + sourceTraces.forEach(trace => { + for (let i = 0; i < trace.messages.length; i += 2) { + const user = trace.messages[i]; + const assistant = trace.messages[i + 1] || null; + const time = getMessageTimestamp(user, 'query'); + pairs.push({ + user: user ? tagMessage(user, trace) : null, + assistant: assistant ? tagMessage(assistant, trace) : null, + time, + trace + }); + } + }); + + pairs.sort((a, b) => a.time - b.time); + + const result: Message[] = []; + pairs.forEach(pair => { + if (pair.user) result.push(pair.user); + if (pair.assistant) result.push(pair.assistant); + }); + return result; + } + + case 'response_time': { + // Sort by response time, keeping Q-A pairs together + const pairs: { user: Message | null; assistant: Message | null; time: number; trace: Trace }[] = []; + + sourceTraces.forEach(trace => { + for (let i = 0; i < trace.messages.length; i += 2) { + const user = trace.messages[i]; + const assistant = trace.messages[i + 1] || null; + const time = getMessageTimestamp(assistant || user, 'response'); + pairs.push({ + user: user ? tagMessage(user, trace) : null, + assistant: assistant ? tagMessage(assistant, trace) : null, + time, + trace + }); + } + }); + + pairs.sort((a, b) => a.time - b.time); + + const result: Message[] = []; + pairs.forEach(pair => { + if (pair.user) result.push(pair.user); + if (pair.assistant) result.push(pair.assistant); + }); + return result; + } + + case 'trace_order': { + // Simply concatenate in the order of sourceTraceIds + const result: Message[] = []; + sourceTraces.forEach(trace => { + trace.messages.forEach(msg => { + result.push(tagMessage(msg, trace)); + }); + }); + return result; + } + + case 'grouped': { + // Same as trace_order but more explicitly grouped + const result: Message[] = []; + sourceTraces.forEach(trace => { + trace.messages.forEach(msg => { + result.push(tagMessage(msg, trace)); + }); + }); + return result; + } + + case 'interleaved': { + // True interleaving - sort ALL messages by their actual time + const allMessages = getAllMessagesWithTime(); + + // Sort by the earlier of query/response time for each message + allMessages.sort((a, b) => { + const aTime = a.msg.role === 'user' ? a.queryTime : a.responseTime; + const bTime = b.msg.role === 'user' ? b.queryTime : b.responseTime; + return aTime - bTime; + }); + + return allMessages.map(m => m.msg); + } + + case 'summary': { + // For summary, we return all messages in trace order with source tags + // The actual summarization is done asynchronously and stored in summarizedContent + const result: Message[] = []; + sourceTraces.forEach(trace => { + trace.messages.forEach(msg => { + result.push(tagMessage(msg, trace)); + }); + }); + return result; + } + + default: + return []; + } + }, + + // Create a new merged trace + createMergedTrace: (nodeId: string, sourceTraceIds: string[], strategy: MergeStrategy): string => { + const { nodes, updateNodeData, computeMergedMessages } = get(); + const node = nodes.find(n => n.id === nodeId); + if (!node) return ''; + + // Get colors from source traces (preserve multi-colors for merged parents) + const colors = sourceTraceIds.flatMap(id => { + const t = node.data.traces.find((tr: Trace) => tr.id === id); + if (!t) return []; + if (t.mergedColors && t.mergedColors.length > 0) return t.mergedColors; + return t.color ? [t.color] : []; + }); + + // Compute merged messages + const messages = computeMergedMessages(nodeId, sourceTraceIds, strategy); + + const mergedTraceId = `merged-${nodeId}-${Date.now()}`; + + const mergedTrace: MergedTrace = { + id: mergedTraceId, + sourceNodeId: nodeId, + sourceTraceIds, + strategy, + colors, + messages + }; + + const existingMerged = node.data.mergedTraces || []; + updateNodeData(nodeId, { + mergedTraces: [...existingMerged, mergedTrace] + }); + + // Trigger trace propagation to update outgoing traces + setTimeout(() => { + get().propagateTraces(); + }, 50); + + return mergedTraceId; + }, + + // Update an existing merged trace + updateMergedTrace: (nodeId: string, mergedTraceId: string, updates: { sourceTraceIds?: string[]; strategy?: MergeStrategy; summarizedContent?: string }) => { + const { nodes, updateNodeData, computeMergedMessages } = get(); + const node = nodes.find(n => n.id === nodeId); + if (!node) return; + + const existingMerged = node.data.mergedTraces || []; + const mergedIndex = existingMerged.findIndex((m: MergedTrace) => m.id === mergedTraceId); + if (mergedIndex === -1) return; + + const current = existingMerged[mergedIndex]; + const newSourceTraceIds = updates.sourceTraceIds || current.sourceTraceIds; + const newStrategy = updates.strategy || current.strategy; + + // Recompute colors if source traces changed (preserve multi-colors) + let newColors = current.colors; + if (updates.sourceTraceIds) { + newColors = updates.sourceTraceIds.flatMap(id => { + const t = node.data.traces.find((tr: Trace) => tr.id === id); + if (!t) return []; + if (t.mergedColors && t.mergedColors.length > 0) return t.mergedColors; + return t.color ? [t.color] : []; + }); + } + + // Recompute messages if source or strategy changed + let newMessages = current.messages; + if (updates.sourceTraceIds || updates.strategy) { + newMessages = computeMergedMessages(nodeId, newSourceTraceIds, newStrategy); + } + + const updatedMerged = [...existingMerged]; + updatedMerged[mergedIndex] = { + ...current, + sourceTraceIds: newSourceTraceIds, + strategy: newStrategy, + colors: newColors, + messages: newMessages, + summarizedContent: updates.summarizedContent !== undefined ? updates.summarizedContent : current.summarizedContent + }; + + updateNodeData(nodeId, { mergedTraces: updatedMerged }); + + // Trigger trace propagation + setTimeout(() => { + get().propagateTraces(); + }, 50); + }, + + // Delete a merged trace + deleteMergedTrace: (nodeId: string, mergedTraceId: string) => { + const { nodes, updateNodeData } = get(); + const node = nodes.find(n => n.id === nodeId); + if (!node) return; + + const existingMerged = node.data.mergedTraces || []; + const filteredMerged = existingMerged.filter((m: MergedTrace) => m.id !== mergedTraceId); + + updateNodeData(nodeId, { mergedTraces: filteredMerged }); + + // Trigger trace propagation + setTimeout(() => { + get().propagateTraces(); + }, 50); + }, + propagateTraces: () => { const { nodes, edges } = get(); @@ -315,6 +1902,14 @@ const useFlowStore = create<FlowState>((set, get) => ({ const nodeOutgoingTraces = new Map<string, Trace[]>(); // Map<NodeID, Trace[]>: Traces ENTERING this node (to update NodeData) const nodeIncomingTraces = new Map<string, Trace[]>(); + // Map<NodeID, string[]>: Merged traces to delete from each node (disconnected sources) + const nodeMergedTracesToDelete = new Map<string, string[]>(); + // Map<NodeID, Map<MergedTraceId, UpdatedMergedTrace>>: Updated merged traces with new messages + const nodeUpdatedMergedTraces = new Map<string, Map<string, { messages: Message[], colors: string[] }>>(); + // Map<NodeID, Message[]>: Prepend messages for each node (for recursive prepend chains) + const nodePrependMessages = new Map<string, Message[]>(); + // Map<NodeID, Trace[]>: Forked traces to keep (cleanup unused ones) + const nodeForkedTracesToClean = new Map<string, Trace[]>(); // Also track Edge updates (Color AND SourceHandle) const updatedEdges = [...edges]; @@ -325,37 +1920,90 @@ const useFlowStore = create<FlowState>((set, get) => ({ const node = nodes.find(n => n.id === nodeId); if (!node) return; - // 1. Gather Incoming Traces + // 1. Gather Incoming Traces (regular and prepend) const incomingEdges = edges.filter(e => e.target === nodeId); const myIncomingTraces: Trace[] = []; - incomingEdges.forEach(edge => { + // Map: traceIdToPrepend -> Message[] (messages to prepend to that trace) + const prependMessages = new Map<string, Message[]>(); + + incomingEdges.forEach(edge => { const parentOutgoing = nodeOutgoingTraces.get(edge.source) || []; + const targetHandle = edge.targetHandle || ''; + + // Check if this is a prepend handle + const isPrependHandle = targetHandle.startsWith('prepend-'); + + // Special case: prepend connection (from a prepend trace created by onConnect) + if (isPrependHandle) { + // Find the source trace - it could be: + // 1. A prepend trace (prepend-xxx-from-yyy format) + // 2. Or any trace from the source handle + let matchedPrependTrace = parentOutgoing.find(t => edge.sourceHandle === `trace-${t.id}`); + + // Fallback: if no exact match, find by sourceHandle pattern + if (!matchedPrependTrace && edge.sourceHandle?.startsWith('trace-')) { + const sourceTraceId = edge.sourceHandle.replace('trace-', ''); + matchedPrependTrace = parentOutgoing.find(t => t.id === sourceTraceId); + } + + // Extract the original target trace ID from the prepend handle + // Format is "prepend-{traceId}" where traceId could be "fork-xxx" or "trace-xxx" + const targetTraceId = targetHandle.replace('prepend-', '').replace('inherited-', ''); + + if (matchedPrependTrace && matchedPrependTrace.messages.length > 0) { + // Prepend the messages from the source trace + const existing = prependMessages.get(targetTraceId) || []; + prependMessages.set(targetTraceId, [...existing, ...matchedPrependTrace.messages]); + + // Update edge color to match the trace + const edgeIndex = updatedEdges.findIndex(e => e.id === edge.id); + if (edgeIndex !== -1) { + const currentEdge = updatedEdges[edgeIndex]; + if (currentEdge.style?.stroke !== matchedPrependTrace.color) { + updatedEdges[edgeIndex] = { + ...currentEdge, + style: { ...currentEdge.style, stroke: matchedPrependTrace.color, strokeWidth: 2 } + }; + } + } + } else { + // Even if no messages yet, store the prepend trace info for later updates + // The prepend connection should still work when the source node gets content + const existing = prependMessages.get(targetTraceId) || []; + if (!prependMessages.has(targetTraceId)) { + prependMessages.set(targetTraceId, existing); + } + } + return; // Don't process prepend edges as regular incoming traces + } // Find match based on Handle ID - // EXACT match first - // Since we removed 'new-trace' handle, we only look for exact trace matches. let matchedTrace = parentOutgoing.find(t => edge.sourceHandle === `trace-${t.id}`); // If no exact match, try to find a "Semantic Match" (Auto-Reconnect) - // If edge.sourceHandle was 'trace-X', and now we have 'trace-X_Parent', that's a likely evolution. if (!matchedTrace && edge.sourceHandle?.startsWith('trace-')) { const oldId = edge.sourceHandle.replace('trace-', ''); matchedTrace = parentOutgoing.find(t => t.id === `${oldId}_${edge.source}`); } // Fallback: If still no match, and parent has traces, try to connect to the most logical one. - // If parent has only 1 trace, connect to it. - // This handles cases where edge.sourceHandle might be null or outdated. if (!matchedTrace && parentOutgoing.length > 0) { - // If edge has no handle ID, default to the last generated trace (usually Self Trace) if (!edge.sourceHandle) { matchedTrace = parentOutgoing[parentOutgoing.length - 1]; } } if (matchedTrace) { - myIncomingTraces.push(matchedTrace); + if (isPrependHandle) { + // This is a prepend connection from a trace handle - extract the target trace ID + const targetTraceId = targetHandle.replace('prepend-', '').replace('inherited-', ''); + const existing = prependMessages.get(targetTraceId) || []; + prependMessages.set(targetTraceId, [...existing, ...matchedTrace.messages]); + } else { + // Regular incoming trace + myIncomingTraces.push(matchedTrace); + } // Update Edge Visuals & Logical Connection const edgeIndex = updatedEdges.findIndex(e => e.id === edge.id); @@ -363,15 +2011,53 @@ const useFlowStore = create<FlowState>((set, get) => ({ const currentEdge = updatedEdges[edgeIndex]; const newHandleId = `trace-${matchedTrace.id}`; - // Check if we need to update - if (currentEdge.sourceHandle !== newHandleId || currentEdge.style?.stroke !== matchedTrace.color) { - updatedEdges[edgeIndex] = { - ...currentEdge, - sourceHandle: newHandleId, // Auto-update handle connection! - style: { ...currentEdge.style, stroke: matchedTrace.color, strokeWidth: 2 } - }; - edgesChanged = true; - } + // Check if this is a merged trace (need gradient) + // Use the new properties on Trace object + const isMergedTrace = matchedTrace.isMerged || matchedTrace.id.startsWith('merged-'); + const mergedColors = matchedTrace.mergedColors || []; + + // If colors not on trace, try to find in parent node's mergedTraces (for originator) + let finalColors = mergedColors; + if (isMergedTrace && finalColors.length === 0) { + const parentNode = nodes.find(n => n.id === edge.source); + const mergedData = parentNode?.data.mergedTraces?.find((m: MergedTrace) => m.id === matchedTrace.id); + if (mergedData) finalColors = mergedData.colors; + } + + // Create gradient for merged traces + let gradient: string | undefined; + if (finalColors.length > 0) { + const gradientStops = finalColors.map((color: string, idx: number) => + `${color} ${(idx / finalColors.length) * 100}%, ${color} ${((idx + 1) / finalColors.length) * 100}%` + ).join(', '); + gradient = `linear-gradient(90deg, ${gradientStops})`; + } + + // Check if we need to update + // Update if handle changed OR color changed OR merged status/colors changed + const currentIsMerged = currentEdge.data?.isMerged; + const currentColors = currentEdge.data?.colors; + const colorsChanged = JSON.stringify(currentColors) !== JSON.stringify(finalColors); + + if (currentEdge.sourceHandle !== newHandleId || + currentEdge.style?.stroke !== matchedTrace.color || + currentIsMerged !== isMergedTrace || + colorsChanged) { + + updatedEdges[edgeIndex] = { + ...currentEdge, + sourceHandle: newHandleId, + type: isMergedTrace ? 'merged' : undefined, + style: { ...currentEdge.style, stroke: matchedTrace.color, strokeWidth: 2 }, + data: { + ...currentEdge.data, + gradient, + isMerged: isMergedTrace, + colors: finalColors + } + }; + edgesChanged = true; + } } } }); @@ -402,54 +2088,153 @@ const useFlowStore = create<FlowState>((set, get) => ({ const myOutgoingTraces: Trace[] = []; - // A. Pass-through traces (append history) + // A. Pass-through traces (append history) - only if there's a downstream edge uniqueIncoming.forEach(t => { - // When a trace passes through a node and gets modified, it effectively becomes a NEW branch of that trace. - // We must append the current node ID to the trace ID to distinguish branches. - // e.g. Trace "root" -> passes Node A -> becomes "root_A" - // If it passes Node B -> becomes "root_B" - // Downstream Node D can then distinguish "root_A" from "root_B". + // SIMPLIFICATION: Keep the same Trace ID for pass-through traces. + // This ensures A-B-C appears as a single continuous trace with the same ID. + // Only branch/fork traces get new IDs. - // Match Logic: - // We need to find if this edge was PREVIOUSLY connected to a trace that has now evolved into 'newTrace'. - // The edge.sourceHandle might be the OLD ID. - // We need a heuristic: if edge.sourceHandle contains the ROOT ID of this trace, we assume it's a match. - // But this is risky if multiple branches exist. + const passThroughId = t.id; - // Better heuristic: - // When we extend a trace t -> t_new (with id t.id + '_' + node.id), - // we record this evolution mapping. + // Only create pass-through if there's actually a downstream edge using it + const hasDownstreamEdge = updatedEdges.some(e => + e.source === node.id && + (e.sourceHandle === `trace-${passThroughId}`) + ); - const newTraceId = `${t.id}_${node.id}`; - - myOutgoingTraces.push({ - ...t, - id: newTraceId, - messages: [...t.messages, ...myResponseMsg] - }); + if (hasDownstreamEdge) { + myOutgoingTraces.push({ + ...t, + id: passThroughId, + // Keep original sourceNodeId - this is a pass-through, not originated here + sourceNodeId: t.sourceNodeId, + messages: [...t.messages, ...myResponseMsg] + }); + } }); - // B. Self Trace (New Branch) -> This is the "Default" self trace (always there?) - // Actually, if we use Manual Forks, maybe we don't need an automatic self trace? - // Or maybe the "Default" self trace is just one of the outgoing ones. - // Let's keep it for compatibility if downstream picks it up automatically. - const selfTrace: Trace = { - id: `trace-${node.id}`, - sourceNodeId: node.id, - color: getStableColor(node.id), - messages: [...myResponseMsg] - }; - myOutgoingTraces.push(selfTrace); + // B. Self Trace (New Branch) -> Only create if there's a downstream edge using it + const selfTraceId = `trace-${node.id}`; + const selfPrepend = prependMessages.get(selfTraceId) || []; + + // Store prepend messages for this node (for recursive prepend chains) + // This includes any prepend from upstream and this node's own messages + if (selfPrepend.length > 0 || myResponseMsg.length > 0) { + nodePrependMessages.set(node.id, [...selfPrepend, ...myResponseMsg]); + } + + // Only add self trace to outgoing if there's actually a downstream edge using it + // Check if any edge uses this self trace as source + // Edge sourceHandle format: "trace-{traceId}" where traceId = "trace-{nodeId}" + // So sourceHandle would be "trace-trace-{nodeId}" + const hasSelfTraceEdge = updatedEdges.some(e => + e.source === node.id && + (e.sourceHandle === `trace-${selfTraceId}` || e.sourceHandle === selfTraceId) + ); - // C. Manual Forks + if (hasSelfTraceEdge) { + const selfTrace: Trace = { + id: selfTraceId, + sourceNodeId: node.id, + color: getStableColor(node.id), + messages: [...selfPrepend, ...myResponseMsg] + }; + myOutgoingTraces.push(selfTrace); + } + + // C. Manual Forks - only include forks that have downstream edges if (node.data.forkedTraces) { - // We need to keep them updated with the latest messages (if prompt changed) - // But keep their IDs and Colors stable. - const updatedForks = node.data.forkedTraces.map(fork => ({ - ...fork, - messages: [...myResponseMsg] // Re-sync messages - })); + // Filter to only forks that are actually being used (have edges) + const activeForks = node.data.forkedTraces.filter(fork => { + return updatedEdges.some(e => + e.source === node.id && e.sourceHandle === `trace-${fork.id}` + ); + }); + + // Update messages for active forks + const updatedForks = activeForks.map(fork => { + const forkPrepend = prependMessages.get(fork.id) || []; + return { + ...fork, + messages: [...forkPrepend, ...myResponseMsg] // Prepend + current messages + }; + }); myOutgoingTraces.push(...updatedForks); + + // Clean up unused forks from node data + if (activeForks.length !== node.data.forkedTraces.length) { + nodeForkedTracesToClean.set(nodeId, activeForks); + } + } + + // D. Merged Traces - recompute messages when source traces change + // Also check if any source traces are disconnected, and mark for deletion + const mergedTracesToDelete: string[] = []; + const updatedMergedMap = new Map<string, { messages: Message[], colors: string[] }>(); + + if (node.data.mergedTraces && node.data.mergedTraces.length > 0) { + const { computeMergedMessages } = get(); + + node.data.mergedTraces.forEach((merged: MergedTrace) => { + // Check if all source traces are still connected + const allSourcesConnected = merged.sourceTraceIds.every(id => + uniqueIncoming.some(t => t.id === id) + ); + + if (!allSourcesConnected) { + // Mark this merged trace for deletion + mergedTracesToDelete.push(merged.id); + return; // Don't add to outgoing traces + } + + // Recompute messages based on the current incoming traces (pass uniqueIncoming for latest data) + const updatedMessages = computeMergedMessages(node.id, merged.sourceTraceIds, merged.strategy, uniqueIncoming); + + // Filter out current node's messages from updatedMessages to avoid duplication + // (since myResponseMsg will be appended at the end) + const nodePrefix = `${node.id}-`; + const filteredMessages = updatedMessages.filter(m => !m.id?.startsWith(nodePrefix)); + + // Get prepend messages for this merged trace + const mergedPrepend = prependMessages.get(merged.id) || []; + + // Update colors from current traces (preserve multi-colors) + const updatedColors = merged.sourceTraceIds.flatMap(id => { + const t = uniqueIncoming.find(trace => trace.id === id); + if (!t) return []; + if (t.mergedColors && t.mergedColors.length > 0) return t.mergedColors; + return t.color ? [t.color] : []; + }); + + // Combine all messages for this merged trace + const mergedMessages = [...mergedPrepend, ...filteredMessages, ...myResponseMsg]; + + // Store updated data for bulk update later + updatedMergedMap.set(merged.id, { messages: mergedMessages, colors: updatedColors }); + + // Create a trace-like object for merged output + const mergedOutgoing: Trace = { + id: merged.id, + sourceNodeId: node.id, + color: updatedColors[0] || getStableColor(merged.id), + messages: mergedMessages, + isMerged: true, + mergedColors: updatedColors, + sourceTraceIds: merged.sourceTraceIds + }; + + myOutgoingTraces.push(mergedOutgoing); + }); + } + + // Store merged traces to delete for this node + if (mergedTracesToDelete.length > 0) { + nodeMergedTracesToDelete.set(nodeId, mergedTracesToDelete); + } + + // Store updated merged trace data for this node + if (updatedMergedMap.size > 0) { + nodeUpdatedMergedTraces.set(nodeId, updatedMergedMap); } nodeOutgoingTraces.set(nodeId, myOutgoingTraces); @@ -461,23 +2246,61 @@ const useFlowStore = create<FlowState>((set, get) => ({ }); // Bulk Update Store + const uniqTraces = (list: Trace[]) => Array.from(new Map(list.map(t => [t.id, t])).values()); + const uniqMerged = (list: MergedTrace[]) => Array.from(new Map(list.map(m => [m.id, m])).values()); + set(state => ({ edges: updatedEdges, nodes: state.nodes.map(n => { - const traces = nodeIncomingTraces.get(n.id) || []; - const outTraces = nodeOutgoingTraces.get(n.id) || []; + const traces = uniqTraces(nodeIncomingTraces.get(n.id) || []); + const outTraces = uniqTraces(nodeOutgoingTraces.get(n.id) || []); + const mergedToDelete = nodeMergedTracesToDelete.get(n.id) || []; + const updatedMerged = nodeUpdatedMergedTraces.get(n.id); + const cleanedForks = nodeForkedTracesToClean.get(n.id); + + // Filter out disconnected merged traces and update messages for remaining ones + let filteredMergedTraces = uniqMerged((n.data.mergedTraces || []).filter( + (m: MergedTrace) => !mergedToDelete.includes(m.id) + )); + + // Apply updated messages and colors to merged traces + if (updatedMerged && updatedMerged.size > 0) { + filteredMergedTraces = filteredMergedTraces.map((m: MergedTrace) => { + const updated = updatedMerged.get(m.id); + if (updated) { + return { ...m, messages: updated.messages, colors: updated.colors }; + } + return m; + }); + } + + // Clean up unused forked traces + const filteredForkedTraces = cleanedForks !== undefined + ? cleanedForks + : (n.data.forkedTraces || []); + + // Also update activeTraceIds to remove deleted merged traces and orphaned fork traces + const activeForkIds = filteredForkedTraces.map(f => f.id); + const filteredActiveTraceIds = (n.data.activeTraceIds || []).filter( + (id: string) => !mergedToDelete.includes(id) && + (activeForkIds.includes(id) || !id.startsWith('fork-')) + ); + return { ...n, data: { ...n.data, traces, outgoingTraces: outTraces, - activeTraceIds: n.data.activeTraceIds + forkedTraces: filteredForkedTraces, + mergedTraces: filteredMergedTraces, + activeTraceIds: filteredActiveTraceIds } }; }) })); - } -})); + }, + }; +}); export default useFlowStore; |
