From f97b7a1bfa220a0947f2cd63c23f4faa9fcd42e7 Mon Sep 17 00:00:00 2001
From: blackhao <13851610112@163.com>
Date: Mon, 8 Dec 2025 15:07:12 -0600
Subject: merge logic

---
 frontend/src/App.tsx | 183 ++++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 158 insertions(+), 25 deletions(-)

(limited to 'frontend/src/App.tsx')

diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9ec1340..5776091 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -6,21 +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, 
@@ -32,24 +38,45 @@ function Flow() {
     deleteEdge,
     deleteNode,
     deleteBranch,
+    deleteTrace,
     setSelectedNode,
     toggleNodeDisabled,
     archiveNode,
     createNodeFromArchive,
-    toggleTraceDisabled
+    toggleTraceDisabled,
+    theme,
+    toggleTheme,
+    autoLayout,
+    findNonOverlappingPosition
   } = 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);
 
-  const onPaneClick = () => {
+  // Get selected nodes
+  const selectedNodes = nodes.filter(n => n.selected);
+
+  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();
@@ -58,7 +85,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) => {
@@ -68,7 +121,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,
@@ -76,7 +142,7 @@ function Flow() {
       position: pos,
       data: { 
         label: 'New Question', 
-        model: 'gpt-4o', 
+        model: 'gpt-5.1', 
         temperature: 0.7,
         systemPrompt: '',
         userPrompt: '',
@@ -86,6 +152,7 @@ function Flow() {
         traces: [],
         outgoingTraces: [],
         forkedTraces: [],
+        mergedTraces: [],
         response: '',
         status: 'idle',
         inputs: 1
@@ -124,11 +191,11 @@ function Flow() {
   };
 
   return (
-    <div style={{ width: '100vw', height: '100vh', display: 'flex' }}>
+    <div className={`w-screen h-screen flex ${theme === 'dark' ? 'dark bg-gray-900' : 'bg-white'}`}>
       <LeftSidebar isOpen={isLeftOpen} onToggle={() => setIsLeftOpen(!isLeftOpen)} />
       
       <div 
-        style={{ flex: 1, height: '100%', position: 'relative' }} 
+        className={`flex-1 h-full relative ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}
         ref={reactFlowWrapper}
         onDragOver={onDragOver}
         onDrop={onDrop}
@@ -140,23 +207,64 @@ function Flow() {
           onEdgesChange={onEdgesChange}
           onConnect={onConnect}
           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>
         
@@ -180,7 +288,27 @@ function Flow() {
                     }
                   } 
                 }
-              ] : menu.type === 'node' ? (() => {
+              ] : 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;
                 
@@ -210,7 +338,7 @@ function Flow() {
                     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);
@@ -230,14 +358,19 @@ function Flow() {
                     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 isOpen={isRightOpen} onToggle={() => setIsRightOpen(!isRightOpen)} />
+      <Sidebar isOpen={isRightOpen} onToggle={() => setIsRightOpen(!isRightOpen)} onInteract={closeMenu} />
     </div>
   );
 }
-- 
cgit v1.2.3

