summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorblackhao <13851610112@163.com>2025-12-09 17:32:10 -0600
committerblackhao <13851610112@163.com>2025-12-09 17:32:10 -0600
commit6abff8474c593118fc52afaa9e0b432346aeffa5 (patch)
tree40d8015b89bf71ad41999a9ac753c3a5534b2b46
parentc3673766aecdb988bb4e811376d4f1f1e18f1e0f (diff)
file management sys
-rw-r--r--backend/app/main.py286
-rw-r--r--backend/data/test/archive/archived_nodes.json1
-rw-r--r--backend/data/test/projects/untitled.json40
-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
7 files changed, 1086 insertions, 32 deletions
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/<user>/projects
+ data/<user>/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/<user> and legacy archive folders to the new data/<user>/ 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<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;