From 6abff8474c593118fc52afaa9e0b432346aeffa5 Mon Sep 17 00:00:00 2001 From: blackhao <13851610112@163.com> Date: Tue, 9 Dec 2025 17:32:10 -0600 Subject: file management sys --- backend/app/main.py | 286 ++++++++++++++- backend/data/test/archive/archived_nodes.json | 1 + backend/data/test/projects/untitled.json | 40 +++ frontend/src/App.tsx | 24 +- frontend/src/components/LeftSidebar.tsx | 481 +++++++++++++++++++++++++- frontend/src/components/Sidebar.tsx | 20 +- frontend/src/store/flowStore.ts | 266 +++++++++++++- 7 files changed, 1086 insertions(+), 32 deletions(-) create mode 100644 backend/data/test/archive/archived_nodes.json create mode 100644 backend/data/test/projects/untitled.json diff --git a/backend/app/main.py b/backend/app/main.py index 65fa3a3..261b45a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,11 +1,14 @@ from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, FileResponse from pydantic import BaseModel from app.schemas import NodeRunRequest, NodeRunResponse, MergeStrategy, Role, Message, Context, LLMConfig, ModelProvider, ReasoningEffort from app.services.llm import llm_streamer, generate_title from dotenv import load_dotenv import os +import json +import shutil +from typing import List, Literal, Optional load_dotenv() @@ -19,6 +22,131 @@ app.add_middleware( allow_headers=["*"], ) +# --------- Project / Blueprint storage --------- +DATA_ROOT = os.path.abspath(os.getenv("DATA_ROOT", os.path.join(os.getcwd(), "data"))) +DEFAULT_USER = "test" +ARCHIVE_FILENAME = "archived_nodes.json" + +def ensure_user_root(user: str) -> str: + """ + Ensures the new data root structure: + data//projects + data//archive + """ + user_root = os.path.join(DATA_ROOT, user) + projects_root = os.path.join(user_root, "projects") + archive_root = os.path.join(user_root, "archive") + os.makedirs(projects_root, exist_ok=True) + os.makedirs(archive_root, exist_ok=True) + return user_root + + +def projects_root(user: str) -> str: + return os.path.join(ensure_user_root(user), "projects") + + +def archive_root(user: str) -> str: + return os.path.join(ensure_user_root(user), "archive") + + +def migrate_legacy_layout(user: str): + """ + Migrate from legacy ./projects/ and legacy archive folders to the new data// structure. + """ + legacy_root = os.path.abspath(os.path.join(os.getcwd(), "projects", user)) + new_projects = projects_root(user) + if os.path.exists(legacy_root) and not os.listdir(new_projects): + try: + for name in os.listdir(legacy_root): + src = os.path.join(legacy_root, name) + dst = os.path.join(new_projects, name) + if not os.path.exists(dst): + shutil.move(src, dst) + except Exception: + pass + # migrate legacy archive (archived/ or .cf_archived/) + legacy_archives = [ + os.path.join(legacy_root, "archived", ARCHIVE_FILENAME), + os.path.join(legacy_root, ".cf_archived", ARCHIVE_FILENAME), + ] + new_archive_file = archived_path(user) + if not os.path.exists(new_archive_file): + for legacy in legacy_archives: + if os.path.exists(legacy): + os.makedirs(os.path.dirname(new_archive_file), exist_ok=True) + try: + shutil.move(legacy, new_archive_file) + except Exception: + pass + +def safe_path(user: str, relative_path: str) -> str: + root = projects_root(user) + norm = os.path.normpath(relative_path).lstrip(os.sep) + full = os.path.abspath(os.path.join(root, norm)) + if not full.startswith(root): + raise HTTPException(status_code=400, detail="Invalid path") + return full + +class FSItem(BaseModel): + name: str + path: str # path relative to user root + type: Literal["file", "folder"] + size: Optional[int] = None + mtime: Optional[float] = None + children: Optional[List["FSItem"]] = None + +FSItem.model_rebuild() + +def list_tree(user: str, relative_path: str = ".") -> List[FSItem]: + migrate_legacy_layout(user) + root = safe_path(user, relative_path) + items: List[FSItem] = [] + for name in sorted(os.listdir(root)): + full = os.path.join(root, name) + rel = os.path.relpath(full, projects_root(user)) + stat = os.stat(full) + if os.path.isdir(full): + items.append(FSItem( + name=name, + path=rel, + type="folder", + size=None, + mtime=stat.st_mtime, + children=list_tree(user, rel) + )) + else: + items.append(FSItem( + name=name, + path=rel, + type="file", + size=stat.st_size, + mtime=stat.st_mtime, + children=None + )) + return items + +class SaveBlueprintRequest(BaseModel): + user: str = DEFAULT_USER + path: str # relative path including filename.json + content: dict + +class RenameRequest(BaseModel): + user: str = DEFAULT_USER + path: str + new_name: Optional[str] = None + new_path: Optional[str] = None + +class FolderRequest(BaseModel): + user: str = DEFAULT_USER + path: str # relative folder path + +class DeleteRequest(BaseModel): + user: str = DEFAULT_USER + path: str + is_folder: bool = False + +# ----------------------------------------------- + @app.get("/") def read_root(): return {"message": "ContextFlow Backend is running"} @@ -117,3 +245,159 @@ async def summarize_endpoint(request: SummarizeRequest): from app.services.llm import summarize_content summary = await summarize_content(request.content, request.model) return SummarizeResponse(summary=summary) + +# ---------------- Project / Blueprint APIs ---------------- +@app.get("/api/projects/tree", response_model=List[FSItem]) +def get_project_tree(user: str = DEFAULT_USER): + """ + List all files/folders for the user under the projects root. + """ + ensure_user_root(user) + return list_tree(user) + + +@app.post("/api/projects/create_folder") +def create_folder(req: FolderRequest): + """ + Create a folder (and parents) under the user's project root. + """ + try: + folder_path = safe_path(req.user, req.path) + os.makedirs(folder_path, exist_ok=True) + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/projects/save_blueprint") +def save_blueprint(req: SaveBlueprintRequest): + """ + Save a blueprint JSON to disk. + """ + try: + full_path = safe_path(req.user, req.path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w", encoding="utf-8") as f: + json.dump(req.content, f, ensure_ascii=False, indent=2) + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/projects/file") +def read_blueprint(user: str = DEFAULT_USER, path: str = ""): + """ + Read a blueprint JSON file. + """ + if not path: + raise HTTPException(status_code=400, detail="path is required") + full_path = safe_path(user, path) + if not os.path.isfile(full_path): + raise HTTPException(status_code=404, detail="file not found") + try: + with open(full_path, "r", encoding="utf-8") as f: + data = json.load(f) + return {"content": data} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/projects/download") +def download_blueprint(user: str = DEFAULT_USER, path: str = ""): + """ + Download a blueprint file. + """ + if not path: + raise HTTPException(status_code=400, detail="path is required") + full_path = safe_path(user, path) + if not os.path.isfile(full_path): + raise HTTPException(status_code=404, detail="file not found") + return FileResponse(full_path, filename=os.path.basename(full_path), media_type="application/json") + + +@app.post("/api/projects/rename") +def rename_item(req: RenameRequest): + """ + Rename or move a file or folder. + - If new_path is provided, it is treated as the target relative path (move). + - Else, new_name is used within the same directory. + """ + try: + src = safe_path(req.user, req.path) + if not os.path.exists(src): + raise HTTPException(status_code=404, detail="source not found") + if req.new_path: + dst = safe_path(req.user, req.new_path) + else: + if not req.new_name: + raise HTTPException(status_code=400, detail="new_name or new_path required") + base_dir = os.path.dirname(src) + dst = os.path.join(base_dir, req.new_name) + # Ensure still inside user root + safe_path(req.user, os.path.relpath(dst, ensure_user_root(req.user))) + os.rename(src, dst) + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/projects/delete") +def delete_item(req: DeleteRequest): + """ + Delete a file or folder. + """ + try: + target = safe_path(req.user, req.path) + if not os.path.exists(target): + raise HTTPException(status_code=404, detail="not found") + if os.path.isdir(target): + if not req.is_folder: + # Prevent deleting folder accidentally unless flagged + raise HTTPException(status_code=400, detail="set is_folder=True to delete folder") + shutil.rmtree(target) + else: + os.remove(target) + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) +# ---------------------------------------------------------- + +# --------------- Archived Nodes APIs ---------------------- +def archived_path(user: str) -> str: + root = archive_root(user) + return os.path.join(root, ARCHIVE_FILENAME) + + +@app.get("/api/projects/archived") +def get_archived_nodes(user: str = DEFAULT_USER): + migrate_legacy_layout(user) + path = archived_path(user) + if not os.path.exists(path): + return {"archived": []} + try: + with open(path, "r", encoding="utf-8") as f: + return {"archived": json.load(f)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/projects/archived") +def save_archived_nodes(payload: dict): + user = payload.get("user", DEFAULT_USER) + data = payload.get("archived", []) + try: + path = archived_path(user) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/data/test/archive/archived_nodes.json b/backend/data/test/archive/archived_nodes.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/backend/data/test/archive/archived_nodes.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/backend/data/test/projects/untitled.json b/backend/data/test/projects/untitled.json new file mode 100644 index 0000000..592b817 --- /dev/null +++ b/backend/data/test/projects/untitled.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "nodes": [ + { + "id": "node_1765320756261", + "type": "llmNode", + "position": { + "x": 110.96800241241141, + "y": 456.9765614414304 + }, + "data": { + "label": "New Question", + "model": "gpt-5.1", + "temperature": 0.7, + "systemPrompt": "", + "userPrompt": "", + "mergeStrategy": "smart", + "reasoningEffort": "medium", + "messages": [], + "traces": [], + "outgoingTraces": [], + "forkedTraces": [], + "mergedTraces": [], + "response": "", + "status": "idle", + "inputs": 1, + "activeTraceIds": [] + }, + "width": 200, + "height": 64 + } + ], + "edges": [], + "viewport": { + "x": 412.49738834764497, + "y": 114.22860771179165, + "zoom": 0.5743491774985177 + }, + "theme": "light" +} \ No newline at end of file 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(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 (
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 = ({ 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(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item?: FSItem } | null>(null); + const [currentFolder, setCurrentFolder] = useState('.'); + const [dragItem, setDragItem] = useState(null); + const [showSaveStatus, setShowSaveStatus] = useState(false); + const [expanded, setExpanded] = useState>(() => 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) => { + 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 ( +
+
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)} + > +
+ {item.type === 'folder' ? ( + + ) : ( + + )} + {item.type === 'folder' ? : } + {stripJson(item.name)} +
+ +
+ {item.type === 'folder' && isExpanded && item.children && item.children.length > 0 && ( +
+ {renderTree(item.children, depth + 1)} +
+ )} +
+ ); + }); + }, [isDark, currentBlueprintPath, expanded, handleLoadFile]); + if (!isOpen) { return (
= ({ isOpen, onToggle }) => { } return ( -
+ }`} + onClick={() => setContextMenu(null)} + onContextMenu={(e) => { + // Default empty-area context menu + if (activeTab === 'project') { + openContextMenu(e); + } + }} + > {/* Header */}
= ({ isOpen, onToggle }) => { {/* Content Area */}
{activeTab === 'project' && ( -
- -

Project settings coming soon

+
+
{ e.preventDefault(); e.stopPropagation(); }} + > + + + + + + + {saveStatus === 'saved' ? 'Saved' : saveStatus === 'saving' ? 'Saving...' : saveStatus === 'error' ? 'Save failed' : ''} + + +
+ {!currentBlueprintPath && ( +
+ No file open; Save will create a new file. +
+ )} + +
{ e.preventDefault(); e.stopPropagation(); }} + /> + +
{ + 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 ? ( +
+ +

No files. Right-click to add.

+
+ ) : ( +
+ {renderTree(projectTree, 0)} +
+ )} +
)} {activeTab === 'files' && ( @@ -127,6 +545,7 @@ const LeftSidebar: React.FC = ({ 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?'…':''}`} >
@@ -151,6 +570,50 @@ const LeftSidebar: React.FC = ({ isOpen, onToggle }) => {
)}
+ + {/* Context Menu */} + {contextMenu && ( +
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 = ( + <> + + + + + ); + if (!item) { + return commonNew; + } + if (item.type === 'file') { + return ( + <> + {commonNew} + + + + + ); + } + // folder + return ( + <> + {commonNew} + + + + ); + })()} +
+ )}
); }; 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 = ({ 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 (
= ({ isOpen, onToggle, onInteract }) => { onChange={() => toggleMergeSelection(trace.id)} /> -
+
+ {traceColors.slice(0, 3).map((c, idx) => ( +
+ ))} + {traceColors.length > 3 && ( +
+ +{traceColors.length - 3} +
+ )} +
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[]; + 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 ) => string; // Returns new node ID + // Blueprint serialization / persistence + serializeBlueprint: (viewport?: ViewportState) => BlueprintDocument; + loadBlueprint: (doc: BlueprintDocument) => ViewportState | undefined; + saveBlueprintFile: (path: string, viewport?: ViewportState) => Promise; + readBlueprintFile: (path: string) => Promise; + refreshProjectTree: () => Promise; + createProjectFolder: (path: string) => Promise; + renameProjectItem: (path: string, newName?: string, newPath?: string) => Promise; + deleteProjectItem: (path: string, isFolder?: boolean) => Promise; + setCurrentBlueprintPath: (path?: string) => void; + setLastViewport: (viewport: ViewportState) => void; + saveCurrentBlueprint: (path?: string, viewport?: ViewportState) => Promise; + clearBlueprint: () => void; + loadArchivedNodes: () => Promise; + saveArchivedNodes: () => Promise; + // Merge trace functions createMergedTrace: ( nodeId: string, @@ -172,12 +221,37 @@ const getStableColor = (str: string) => { return `hsl(${hue}, 70%, 60%)`; }; -const useFlowStore = create((set, get) => ({ +const API_BASE = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'; +const DEFAULT_USER = 'test'; + +const jsonFetch = async (url: string, options?: RequestInit): Promise => { + 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; +}; + +const useFlowStore = create((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((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((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((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((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 => { + 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( + `${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((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((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((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((set, get) => ({ }; }) })); - } -})); + }, + }; +}); export default useFlowStore; -- cgit v1.2.3