summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/App.tsx24
-rw-r--r--frontend/src/components/LeftSidebar.tsx481
-rw-r--r--frontend/src/components/Sidebar.tsx20
-rw-r--r--frontend/src/store/flowStore.ts266
4 files changed, 760 insertions, 31 deletions
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5776091..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,
@@ -47,7 +47,10 @@ function Flow() {
theme,
toggleTheme,
autoLayout,
- findNonOverlappingPosition
+ findNonOverlappingPosition,
+ setLastViewport,
+ saveCurrentBlueprint,
+ currentBlueprintPath
} = useFlowStore();
const reactFlowWrapper = useRef<HTMLDivElement>(null);
@@ -190,6 +193,22 @@ function Flow() {
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 className={`w-screen h-screen flex ${theme === 'dark' ? 'dark bg-gray-900' : 'bg-white'}`}>
<LeftSidebar isOpen={isLeftOpen} onToggle={() => setIsLeftOpen(!isLeftOpen)} />
@@ -206,6 +225,7 @@ function Flow() {
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
+ onMoveEnd={(_, viewport) => setLastViewport(viewport)}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={{ type: 'merged' }}
diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx
index 1eaa62c..1b7ccb2 100644
--- a/frontend/src/components/LeftSidebar.tsx
+++ b/frontend/src/components/LeftSidebar.tsx
@@ -1,6 +1,10 @@
-import React, { useState } from 'react';
-import { Folder, FileText, Archive, ChevronLeft, ChevronRight, Trash2, MessageSquare } from 'lucide-react';
-import useFlowStore from '../store/flowStore';
+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 } from '../store/flowStore';
interface LeftSidebarProps {
isOpen: boolean;
@@ -9,14 +13,325 @@ interface LeftSidebarProps {
const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => {
const [activeTab, setActiveTab] = useState<'project' | 'files' | 'archive'>('project');
- const { archivedNodes, removeFromArchive, createNodeFromArchive, theme } = useFlowStore();
+ const {
+ archivedNodes,
+ removeFromArchive,
+ createNodeFromArchive,
+ theme,
+ projectTree,
+ currentBlueprintPath,
+ saveStatus,
+ refreshProjectTree,
+ loadArchivedNodes,
+ 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 [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]);
+
+ // 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();
+ };
+
+ // 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 ${
@@ -40,9 +355,18 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => {
}
return (
- <div className={`w-64 border-r h-screen flex flex-col shadow-xl z-10 transition-all duration-300 ${
+ <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'
@@ -93,9 +417,103 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => {
{/* Content Area */}
<div className={`flex-1 overflow-y-auto p-4 text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
{activeTab === 'project' && (
- <div className="flex flex-col items-center justify-center h-full opacity-50">
- <Folder size={48} className="mb-2" />
- <p>Project settings coming soon</p>
+ <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' && (
@@ -127,6 +545,7 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => {
? '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">
@@ -151,6 +570,50 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => {
</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>
);
};
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 06c8704..3008ba3 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -1875,6 +1875,9 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
.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
@@ -1898,7 +1901,22 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
onChange={() => toggleMergeSelection(trace.id)}
/>
- <div className="w-3 h-3 rounded-full" style={{ backgroundColor: trace.color }}></div>
+ <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'}`}>
diff --git a/frontend/src/store/flowStore.ts b/frontend/src/store/flowStore.ts
index 5ed66e6..56ade75 100644
--- a/frontend/src/store/flowStore.ts
+++ b/frontend/src/store/flowStore.ts
@@ -15,7 +15,32 @@ 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;
@@ -90,6 +115,10 @@ export interface ArchivedNode {
systemPrompt: string;
temperature: number;
reasoningEffort: 'low' | 'medium' | 'high';
+ userPrompt?: string;
+ response?: string;
+ enableGoogleSearch?: boolean;
+ mergeStrategy?: 'raw' | 'smart';
}
interface FlowState {
@@ -98,6 +127,10 @@ interface FlowState {
selectedNodeId: string | null;
archivedNodes: ArchivedNode[]; // Stored node templates
theme: 'light' | 'dark';
+ projectTree: FSItem[];
+ currentBlueprintPath?: string;
+ lastViewport?: ViewportState;
+ saveStatus: SaveStatus;
onNodesChange: OnNodesChange;
onEdgesChange: OnEdgesChange;
@@ -140,6 +173,22 @@ interface FlowState {
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>;
+
// Merge trace functions
createMergedTrace: (
nodeId: string,
@@ -172,12 +221,37 @@ 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: [],
theme: 'light' as const,
+ projectTree: [],
+ currentBlueprintPath: undefined,
+ lastViewport: undefined,
+ saveStatus: 'idle',
toggleTheme: () => {
const newTheme = get().theme === 'light' ? 'dark' : 'light';
@@ -190,6 +264,10 @@ const useFlowStore = create<FlowState>((set, get) => ({
}
},
+ setLastViewport: (viewport: ViewportState) => {
+ set({ lastViewport: viewport });
+ },
+
findNonOverlappingPosition: (baseX: number, baseY: number) => {
const { nodes } = get();
// Estimate larger dimensions to be safe, considering dynamic handles
@@ -1036,18 +1114,24 @@ const useFlowStore = create<FlowState>((set, get) => ({
model: node.data.model,
systemPrompt: node.data.systemPrompt,
temperature: node.data.temperature,
- reasoningEffort: node.data.reasoningEffort || 'medium'
+ 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 }) => {
@@ -1063,15 +1147,16 @@ const useFlowStore = create<FlowState>((set, get) => ({
model: archived.model,
temperature: archived.temperature,
systemPrompt: archived.systemPrompt,
- userPrompt: '',
- mergeStrategy: 'smart',
+ userPrompt: archived.userPrompt || '',
reasoningEffort: archived.reasoningEffort,
+ enableGoogleSearch: archived.enableGoogleSearch,
+ mergeStrategy: archived.mergeStrategy || 'smart',
traces: [],
outgoingTraces: [],
forkedTraces: [],
mergedTraces: [],
activeTraceIds: [],
- response: '',
+ response: archived.response || '',
status: 'idle',
inputs: 1
}
@@ -1313,6 +1398,139 @@ const useFlowStore = create<FlowState>((set, get) => ({
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),
+ });
+ },
+
+ // --------------------------------------------------------
+
// 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[] => {
@@ -1487,10 +1705,13 @@ const useFlowStore = create<FlowState>((set, get) => ({
const node = nodes.find(n => n.id === nodeId);
if (!node) return '';
- // Get colors from source traces
- const colors = sourceTraceIds
- .map(id => node.data.traces.find((t: Trace) => t.id === id)?.color)
- .filter((c): c is string => c !== undefined);
+ // 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);
@@ -1533,12 +1754,15 @@ const useFlowStore = create<FlowState>((set, get) => ({
const newSourceTraceIds = updates.sourceTraceIds || current.sourceTraceIds;
const newStrategy = updates.strategy || current.strategy;
- // Recompute colors if source traces changed
+ // Recompute colors if source traces changed (preserve multi-colors)
let newColors = current.colors;
if (updates.sourceTraceIds) {
- newColors = updates.sourceTraceIds
- .map(id => node.data.traces.find((t: Trace) => t.id === id)?.color)
- .filter((c): c is string => c !== undefined);
+ 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
@@ -1919,10 +2143,13 @@ const useFlowStore = create<FlowState>((set, get) => ({
// Get prepend messages for this merged trace
const mergedPrepend = prependMessages.get(merged.id) || [];
- // Update colors from current traces
- const updatedColors = merged.sourceTraceIds
- .map(id => uniqueIncoming.find(t => t.id === id)?.color)
- .filter((c): c is string => c !== undefined);
+ // 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];
@@ -2017,7 +2244,8 @@ const useFlowStore = create<FlowState>((set, get) => ({
};
})
}));
- }
-}));
+ },
+ };
+});
export default useFlowStore;