diff options
| author | blackhao <13851610112@163.com> | 2025-12-09 17:32:10 -0600 |
|---|---|---|
| committer | blackhao <13851610112@163.com> | 2025-12-09 17:32:10 -0600 |
| commit | 6abff8474c593118fc52afaa9e0b432346aeffa5 (patch) | |
| tree | 40d8015b89bf71ad41999a9ac753c3a5534b2b46 /frontend/src | |
| parent | c3673766aecdb988bb4e811376d4f1f1e18f1e0f (diff) | |
file management sys
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/App.tsx | 24 | ||||
| -rw-r--r-- | frontend/src/components/LeftSidebar.tsx | 481 | ||||
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 20 | ||||
| -rw-r--r-- | frontend/src/store/flowStore.ts | 266 |
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; |
