summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorblackhao <13851610112@163.com>2025-12-09 18:04:33 -0600
committerblackhao <13851610112@163.com>2025-12-09 18:04:33 -0600
commit0dcaf9d7da9fa5041fbd5489a60886ceb416b1d4 (patch)
treead3d31fea269aa49392c1ae013bee91d1fdfa18d
parent6abff8474c593118fc52afaa9e0b432346aeffa5 (diff)
upload files to backend
-rw-r--r--backend/app/main.py100
-rw-r--r--backend/data/test/files/58252c05-718e-4eea-a23d-56a64d03b4fcbin0 -> 263914 bytes
-rw-r--r--backend/data/test/files/index.json11
-rw-r--r--backend/requirements.txt1
-rw-r--r--frontend/src/components/LeftSidebar.tsx136
-rw-r--r--frontend/src/store/flowStore.ts55
6 files changed, 299 insertions, 4 deletions
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/<user> and legacy archive folders to the new data/<user>/ 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
--- /dev/null
+++ b/backend/data/test/files/58252c05-718e-4eea-a23d-56a64d03b4fc
Binary files 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<LeftSidebarProps> = ({ 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<LeftSidebarProps> = ({ isOpen, onToggle }) => {
const { setViewport, getViewport } = useReactFlow();
const isDark = theme === 'dark';
const fileInputRef = useRef<HTMLInputElement | null>(null);
+ const fileUploadRef = 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);
@@ -93,6 +98,13 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ 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<LeftSidebarProps> = ({ 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<HTMLInputElement>) => {
+ 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<LeftSidebarProps> = ({ isOpen, onToggle }) => {
</div>
)}
{activeTab === 'files' && (
- <div className="flex flex-col items-center justify-center h-full opacity-50">
- <FileText size={48} className="mb-2" />
- <p>File manager coming soon</p>
+ <div
+ className="flex flex-col h-full gap-2"
+ onDragOver={(e) => { if (e.dataTransfer.types.includes('Files')) e.preventDefault(); }}
+ onDrop={async (e) => {
+ if (e.dataTransfer.files?.length) {
+ e.preventDefault();
+ await handleFilesUpload(e.dataTransfer.files);
+ }
+ }}
+ >
+ <div className="flex items-center gap-2">
+ <button
+ onClick={() => refreshFiles()}
+ className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}
+ title="Refresh files"
+ >
+ <RefreshCw size={14} />
+ </button>
+ <button
+ onClick={() => fileUploadRef.current?.click()}
+ className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}
+ title="Upload files (drag & drop supported)"
+ >
+ <Upload size={14} />
+ </button>
+ <input
+ ref={fileUploadRef}
+ type="file"
+ multiple
+ className="hidden"
+ onChange={handleFilesInputChange}
+ />
+ <span className={`text-xs ${isDark ? 'text-gray-500' : 'text-gray-500'}`}>Drag files here or click upload</span>
+ </div>
+
+ {files.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-full opacity-50 border border-dashed border-gray-300 dark:border-gray-700 rounded">
+ <FileText size={32} className="mb-2" />
+ <p className="text-xs text-center">No files uploaded yet.</p>
+ </div>
+ ) : (
+ <div className="flex-1 overflow-y-auto space-y-1">
+ {files.map(f => (
+ <div
+ key={f.id}
+ className={`flex items-center justify-between px-2 py-1 rounded border ${isDark ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}
+ >
+ <div className="flex flex-col">
+ <span className="text-sm font-medium">{f.name}</span>
+ <span className={`text-[11px] ${isDark ? 'text-gray-500' : 'text-gray-500'}`}>
+ {formatSize(f.size)} • {new Date(f.created_at * 1000).toLocaleString()}
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <button
+ onClick={() => handleDownloadFile(f)}
+ className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}
+ title="Download"
+ >
+ <Download size={14} />
+ </button>
+ <button
+ onClick={async () => { if (confirm('Delete this file?')) { await deleteFile(f.id); } }}
+ className={`p-1 rounded ${isDark ? 'hover:bg-red-900 text-red-300' : 'hover:bg-red-50 text-red-600'}`}
+ title="Delete"
+ >
+ <Trash2 size={14} />
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
</div>
)}
{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<void>;
saveArchivedNodes: () => Promise<void>;
+ refreshFiles: () => Promise<void>;
+ uploadFile: (file: File) => Promise<FileMeta>;
+ deleteFile: (fileId: string) => Promise<void>;
+ setFiles: (files: FileMeta[]) => void;
// Merge trace functions
createMergedTrace: (
@@ -247,6 +262,7 @@ const useFlowStore = create<FlowState>((set, get) => {
edges: [],
selectedNodeId: null,
archivedNodes: [],
+ files: [],
theme: 'light' as const,
projectTree: [],
currentBlueprintPath: undefined,
@@ -268,6 +284,9 @@ const useFlowStore = create<FlowState>((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<FlowState>((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