From 0dcaf9d7da9fa5041fbd5489a60886ceb416b1d4 Mon Sep 17 00:00:00 2001 From: blackhao <13851610112@163.com> Date: Tue, 9 Dec 2025 18:04:33 -0600 Subject: upload files to backend --- backend/app/main.py | 100 +++++++++++++++ .../files/58252c05-718e-4eea-a23d-56a64d03b4fc | Bin 0 -> 263914 bytes backend/data/test/files/index.json | 11 ++ backend/requirements.txt | 1 + frontend/src/components/LeftSidebar.tsx | 136 ++++++++++++++++++++- frontend/src/store/flowStore.ts | 55 +++++++++ 6 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 backend/data/test/files/58252c05-718e-4eea-a23d-56a64d03b4fc create mode 100644 backend/data/test/files/index.json diff --git a/backend/app/main.py b/backend/app/main.py index 261b45a..886bd9e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse, FileResponse +from fastapi import UploadFile, File 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 @@ -9,6 +10,7 @@ import os import json import shutil from typing import List, Literal, Optional +from uuid import uuid4 load_dotenv() @@ -49,6 +51,12 @@ def archive_root(user: str) -> str: return os.path.join(ensure_user_root(user), "archive") +def files_root(user: str) -> str: + root = os.path.join(ensure_user_root(user), "files") + os.makedirs(root, exist_ok=True) + return root + + def migrate_legacy_layout(user: str): """ Migrate from legacy ./projects/ and legacy archive folders to the new data// structure. @@ -136,6 +144,15 @@ class RenameRequest(BaseModel): new_name: Optional[str] = None new_path: Optional[str] = None +class FileMeta(BaseModel): + id: str + name: str + size: int + mime: str + created_at: float + provider: Optional[str] = None + provider_file_id: Optional[str] = None + class FolderRequest(BaseModel): user: str = DEFAULT_USER path: str # relative folder path @@ -375,6 +392,27 @@ def archived_path(user: str) -> str: root = archive_root(user) return os.path.join(root, ARCHIVE_FILENAME) +# ---------------- Files (uploads) ---------------- +def files_index_path(user: str) -> str: + return os.path.join(files_root(user), "index.json") + + +def load_files_index(user: str) -> List[FileMeta]: + path = files_index_path(user) + if not os.path.exists(path): + return [] + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return [FileMeta(**item) for item in data] + + +def save_files_index(user: str, items: List[FileMeta]): + path = files_index_path(user) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump([item.model_dump() for item in items], f, ensure_ascii=False, indent=2) + +# ------------------------------------------------- @app.get("/api/projects/archived") def get_archived_nodes(user: str = DEFAULT_USER): @@ -401,3 +439,65 @@ def save_archived_nodes(payload: dict): return {"ok": True} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/files") +def list_files(user: str = DEFAULT_USER): + migrate_legacy_layout(user) + items = load_files_index(user) + return {"files": [item.model_dump() for item in items]} + + +@app.post("/api/files/upload") +async def upload_file(user: str = DEFAULT_USER, file: UploadFile = File(...)): + migrate_legacy_layout(user) + items = load_files_index(user) + file_id = str(uuid4()) + dest_root = files_root(user) + dest_path = os.path.join(dest_root, file_id) + try: + content = await file.read() + size = len(content) + with open(dest_path, "wb") as f: + f.write(content) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + meta = FileMeta( + id=file_id, + name=file.filename, + size=size, + mime=file.content_type or "application/octet-stream", + created_at=os.path.getmtime(dest_path), + ) + items.append(meta) + save_files_index(user, items) + return {"file": meta} + + +@app.get("/api/files/download") +def download_file(user: str = DEFAULT_USER, file_id: str = ""): + migrate_legacy_layout(user) + items = load_files_index(user) + meta = next((i for i in items if i.id == file_id), None) + if not meta: + raise HTTPException(status_code=404, detail="file not found") + path = os.path.join(files_root(user), file_id) + if not os.path.exists(path): + raise HTTPException(status_code=404, detail="file missing on disk") + return FileResponse(path, filename=meta.name, media_type=meta.mime) + + +@app.post("/api/files/delete") +def delete_file(user: str = DEFAULT_USER, file_id: str = ""): + migrate_legacy_layout(user) + items = load_files_index(user) + meta = next((i for i in items if i.id == file_id), None) + if not meta: + raise HTTPException(status_code=404, detail="file not found") + path = os.path.join(files_root(user), file_id) + if os.path.exists(path): + os.remove(path) + items = [i for i in items if i.id != file_id] + save_files_index(user, items) + return {"ok": True} diff --git a/backend/data/test/files/58252c05-718e-4eea-a23d-56a64d03b4fc b/backend/data/test/files/58252c05-718e-4eea-a23d-56a64d03b4fc new file mode 100644 index 0000000..a837816 Binary files /dev/null and b/backend/data/test/files/58252c05-718e-4eea-a23d-56a64d03b4fc differ diff --git a/backend/data/test/files/index.json b/backend/data/test/files/index.json new file mode 100644 index 0000000..d9e559f --- /dev/null +++ b/backend/data/test/files/index.json @@ -0,0 +1,11 @@ +[ + { + "id": "58252c05-718e-4eea-a23d-56a64d03b4fc", + "name": "survey_on_survey (3).pdf", + "size": 263914, + "mime": "application/pdf", + "created_at": 1765324835.7883003, + "provider": null, + "provider_file_id": null + } +] \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 545f6b7..e340864 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,4 +5,5 @@ openai google-generativeai python-dotenv httpx +python-multipart diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx index 1b7ccb2..a75df39 100644 --- a/frontend/src/components/LeftSidebar.tsx +++ b/frontend/src/components/LeftSidebar.tsx @@ -4,7 +4,7 @@ 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'; +import useFlowStore, { type FSItem, type BlueprintDocument, type FileMeta } from '../store/flowStore'; interface LeftSidebarProps { isOpen: boolean; @@ -18,11 +18,15 @@ const LeftSidebar: React.FC = ({ isOpen, onToggle }) => { removeFromArchive, createNodeFromArchive, theme, + files, projectTree, currentBlueprintPath, saveStatus, refreshProjectTree, loadArchivedNodes, + refreshFiles, + uploadFile, + deleteFile, readBlueprintFile, loadBlueprint, saveBlueprintFile, @@ -37,6 +41,7 @@ const LeftSidebar: React.FC = ({ isOpen, onToggle }) => { const { setViewport, getViewport } = useReactFlow(); const isDark = theme === 'dark'; const fileInputRef = useRef(null); + const fileUploadRef = useRef(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item?: FSItem } | null>(null); const [currentFolder, setCurrentFolder] = useState('.'); const [dragItem, setDragItem] = useState(null); @@ -93,6 +98,13 @@ const LeftSidebar: React.FC = ({ isOpen, onToggle }) => { loadArchivedNodes().catch(() => {}); }, [loadArchivedNodes]); + // Load files when entering files tab + useEffect(() => { + if (activeTab === 'files') { + refreshFiles().catch(() => {}); + } + }, [activeTab, refreshFiles]); + // Context menu handlers const openContextMenu = (e: React.MouseEvent, item?: FSItem) => { e.preventDefault(); @@ -217,6 +229,52 @@ const LeftSidebar: React.FC = ({ isOpen, onToggle }) => { await refreshProjectTree(); }; + // Files tab handlers + const handleFilesUpload = async (list: FileList) => { + let ok = 0; + let failed: string[] = []; + for (const f of Array.from(list)) { + try { + await uploadFile(f); + ok += 1; + } catch (e) { + console.error(e); + failed.push(`${f.name}: ${(e as Error).message}`); + } + } + await refreshFiles(); + if (failed.length) { + alert(`Some files failed:\n${failed.join('\n')}`); + } else if (ok > 0) { + // Optional: brief feedback + console.info(`Uploaded ${ok} file(s)`); + } + }; + + const handleFilesInputChange = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + await handleFilesUpload(files); + e.target.value = ''; + } + }; + + const handleDownloadFile = (file: FileMeta) => { + const url = `${import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'}/api/files/download?user=test&file_id=${encodeURIComponent(file.id)}`; + const a = document.createElement('a'); + a.href = url; + a.download = file.name; + a.click(); + }; + + const formatSize = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; + }; + // Fade-out for "Saved" indicator useEffect(() => { if (saveStatus === 'saved') { @@ -517,9 +575,79 @@ const LeftSidebar: React.FC = ({ isOpen, onToggle }) => { )} {activeTab === 'files' && ( -
- -

File manager coming soon

+
{ if (e.dataTransfer.types.includes('Files')) e.preventDefault(); }} + onDrop={async (e) => { + if (e.dataTransfer.files?.length) { + e.preventDefault(); + await handleFilesUpload(e.dataTransfer.files); + } + }} + > +
+ + + + Drag files here or click upload +
+ + {files.length === 0 ? ( +
+ +

No files uploaded yet.

+
+ ) : ( +
+ {files.map(f => ( +
+
+ {f.name} + + {formatSize(f.size)} • {new Date(f.created_at * 1000).toLocaleString()} + +
+
+ + +
+
+ ))} +
+ )}
)} {activeTab === 'archive' && ( diff --git a/frontend/src/store/flowStore.ts b/frontend/src/store/flowStore.ts index 56ade75..a049a8a 100644 --- a/frontend/src/store/flowStore.ts +++ b/frontend/src/store/flowStore.ts @@ -121,11 +121,22 @@ export interface ArchivedNode { mergeStrategy?: 'raw' | 'smart'; } +export interface FileMeta { + id: string; + name: string; + size: number; + mime: string; + created_at: number; + provider?: string; + provider_file_id?: string; +} + interface FlowState { nodes: LLMNode[]; edges: Edge[]; selectedNodeId: string | null; archivedNodes: ArchivedNode[]; // Stored node templates + files: FileMeta[]; theme: 'light' | 'dark'; projectTree: FSItem[]; currentBlueprintPath?: string; @@ -188,6 +199,10 @@ interface FlowState { clearBlueprint: () => void; loadArchivedNodes: () => Promise; saveArchivedNodes: () => Promise; + refreshFiles: () => Promise; + uploadFile: (file: File) => Promise; + deleteFile: (fileId: string) => Promise; + setFiles: (files: FileMeta[]) => void; // Merge trace functions createMergedTrace: ( @@ -247,6 +262,7 @@ const useFlowStore = create((set, get) => { edges: [], selectedNodeId: null, archivedNodes: [], + files: [], theme: 'light' as const, projectTree: [], currentBlueprintPath: undefined, @@ -268,6 +284,9 @@ const useFlowStore = create((set, get) => { set({ lastViewport: viewport }); }, + setFiles: (files: FileMeta[]) => { + set({ files }); + }, findNonOverlappingPosition: (baseX: number, baseY: number) => { const { nodes } = get(); // Estimate larger dimensions to be safe, considering dynamic handles @@ -1529,6 +1548,42 @@ const useFlowStore = create((set, get) => { }); }, + // Files management + refreshFiles: async () => { + const res = await jsonFetch<{ files: FileMeta[] }>( + `${API_BASE}/api/files?user=${encodeURIComponent(DEFAULT_USER)}` + ); + set({ files: res.files || [] }); + }, + + uploadFile: async (file: File) => { + const form = new FormData(); + form.append('file', file); + const res = await fetch(`${API_BASE}/api/files/upload?user=${encodeURIComponent(DEFAULT_USER)}`, { + method: 'POST', + body: form, + }); + if (!res.ok) { + throw new Error(await res.text()); + } + const data = await res.json(); + if (!data.file) { + throw new Error('Upload succeeded but no file info returned'); + } + await get().refreshFiles(); + return data.file as FileMeta; + }, + + deleteFile: async (fileId: string) => { + const res = await fetch(`${API_BASE}/api/files/delete?user=${encodeURIComponent(DEFAULT_USER)}&file_id=${encodeURIComponent(fileId)}`, { + method: 'POST', + }); + if (!res.ok) { + throw new Error(await res.text()); + } + await get().refreshFiles(); + }, + // -------------------------------------------------------- // Compute merged messages based on strategy -- cgit v1.2.3