summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/src/App.tsx308
-rw-r--r--frontend/src/components/ContextMenu.tsx22
-rw-r--r--frontend/src/components/LeftSidebar.tsx750
-rw-r--r--frontend/src/components/Sidebar.tsx2116
-rw-r--r--frontend/src/components/edges/MergedEdge.tsx77
-rw-r--r--frontend/src/components/nodes/LLMNode.tsx264
-rw-r--r--frontend/src/index.css182
-rw-r--r--frontend/src/store/flowStore.ts2041
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;