From 9cd703788e2bab095d1bd30a98cc990d4a894a5d Mon Sep 17 00:00:00 2001 From: marko-kraemer Date: Fri, 11 Apr 2025 13:56:50 +0100 Subject: [PATCH] sandbox file upload, list files v1 --- backend/sandbox/api.py | 60 +++- backend/sandbox/sandbox.py | 4 +- .../projects/[id]/threads/[threadId]/page.tsx | 22 -- frontend/src/components/file-viewer-modal.tsx | 316 +++++++++++------- frontend/src/lib/api.ts | 6 + 5 files changed, 243 insertions(+), 165 deletions(-) diff --git a/backend/sandbox/api.py b/backend/sandbox/api.py index 8e2f7b02..2cdd3386 100644 --- a/backend/sandbox/api.py +++ b/backend/sandbox/api.py @@ -1,8 +1,8 @@ import os -from typing import List, Optional, Union, BinaryIO +from typing import List, Optional -from fastapi import FastAPI, UploadFile, File, HTTPException, APIRouter -from fastapi.responses import FileResponse, JSONResponse, Response +from fastapi import FastAPI, UploadFile, File, HTTPException, APIRouter, Form +from fastapi.responses import Response, JSONResponse from pydantic import BaseModel from utils.logger import logger @@ -19,31 +19,56 @@ class FileInfo(BaseModel): mod_time: str permissions: Optional[str] = None -class FileContentRequest(BaseModel): - """Request model for file content operations""" - path: str - content: str - # Create a router for the Sandbox API router = APIRouter(tags=["sandbox"]) @router.post("/sandboxes/{sandbox_id}/files") -async def create_file(sandbox_id: str, file_request: FileContentRequest): - """Create a file in the sandbox""" +async def create_file( + sandbox_id: str, + path: str = Form(...), + file: UploadFile = File(...) +): + """Create a file in the sandbox using direct file upload""" try: - # Get or start sandbox instance using the async function + # Get or start sandbox instance sandbox = await get_or_start_sandbox(sandbox_id) - # Prepare content - content = file_request.content + # Read file content directly from the uploaded file + content = await file.read() + + # Create file using raw binary content + sandbox.fs.upload_file(path, content) + logger.info(f"File created at {path} in sandbox {sandbox_id}") + + return {"status": "success", "created": True, "path": path} + except Exception as e: + logger.error(f"Error creating file in sandbox {sandbox_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +# For backward compatibility, keep the JSON version too +@router.post("/sandboxes/{sandbox_id}/files/json") +async def create_file_json(sandbox_id: str, file_request: dict): + """Create a file in the sandbox using JSON (legacy support)""" + try: + # Get or start sandbox instance + sandbox = await get_or_start_sandbox(sandbox_id) + + # Get file path and content + path = file_request.get("path") + content = file_request.get("content", "") + + if not path: + raise HTTPException(status_code=400, detail="File path is required") + + # Convert string content to bytes if isinstance(content, str): content = content.encode('utf-8') # Create file - sandbox.fs.upload_file(file_request.path, content) - logger.info(f"File created at {file_request.path} in sandbox {sandbox_id}") + sandbox.fs.upload_file(path, content) + logger.info(f"File created at {path} in sandbox {sandbox_id}") - return {"status": "success", "created": True} + return {"status": "success", "created": True, "path": path} except Exception as e: logger.error(f"Error creating file in sandbox {sandbox_id}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @@ -86,8 +111,7 @@ async def read_file(sandbox_id: str, path: str): # Read file content = sandbox.fs.download_file(path) - # Instead of using FileResponse with content parameter (which doesn't exist), - # return a Response object with the content directly + # Return a Response object with the content directly filename = os.path.basename(path) return Response( content=content, diff --git a/backend/sandbox/sandbox.py b/backend/sandbox/sandbox.py index f22a88d0..6b15fdda 100644 --- a/backend/sandbox/sandbox.py +++ b/backend/sandbox/sandbox.py @@ -299,14 +299,14 @@ async def get_or_start_sandbox(sandbox_id: str): logger.info("Browser API is not running. Starting it...") start_sandbox_browser_api(sandbox) wait_for_api_ready(sandbox) + start_http_server(sandbox) except requests.exceptions.RequestException: logger.info("Browser API is not accessible. Starting it...") start_sandbox_browser_api(sandbox) wait_for_api_ready(sandbox) + start_http_server(sandbox) - # Ensure HTTP server is running - start_http_server(sandbox) logger.info(f"Sandbox {sandbox_id} is ready") return sandbox diff --git a/frontend/src/app/(dashboard)/projects/[id]/threads/[threadId]/page.tsx b/frontend/src/app/(dashboard)/projects/[id]/threads/[threadId]/page.tsx index abfdffbd..cd785334 100644 --- a/frontend/src/app/(dashboard)/projects/[id]/threads/[threadId]/page.tsx +++ b/frontend/src/app/(dashboard)/projects/[id]/threads/[threadId]/page.tsx @@ -813,14 +813,6 @@ export default function ThreadPage({ params }: { params: Promise } setFileViewerOpen(true); }; - // Handle file selection from the file viewer - const handleSelectFile = (path: string, content: string) => { - // Insert file path and first few lines as a message - const previewContent = content.split('\n').slice(0, 5).join('\n'); - const fileMessage = `File: ${path}\n\n\`\`\`\n${previewContent}${content.split('\n').length > 5 ? '\n...' : ''}\n\`\`\``; - setNewMessage(fileMessage); - }; - // Only show a full-screen loader on the very first load if (isAuthLoading || (isLoading && !initialLoadCompleted.current)) { return ( @@ -1052,26 +1044,12 @@ export default function ThreadPage({ params }: { params: Promise } sandboxId={sandboxId || undefined} /> - {sandboxId && ( -
- -
- )} - {/* File Viewer Modal */} {sandboxId && ( )} diff --git a/frontend/src/components/file-viewer-modal.tsx b/frontend/src/components/file-viewer-modal.tsx index 693b072b..031f03bc 100644 --- a/frontend/src/components/file-viewer-modal.tsx +++ b/frontend/src/components/file-viewer-modal.tsx @@ -9,25 +9,28 @@ import { Folder, FolderOpen, Upload, - X, Download, - Copy + ChevronRight, + Home, + ArrowLeft } from "lucide-react"; -import { listSandboxFiles, getSandboxFileContent, createSandboxFile, type FileInfo } from "@/lib/api"; +import { listSandboxFiles, getSandboxFileContent, type FileInfo } from "@/lib/api"; import { toast } from "sonner"; +import { createClient } from "@/utils/supabase/client"; + +// Define API_URL +const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; interface FileViewerModalProps { open: boolean; onOpenChange: (open: boolean) => void; sandboxId: string; - onSelectFile?: (path: string, content: string) => void; } export function FileViewerModal({ open, onOpenChange, - sandboxId, - onSelectFile + sandboxId }: FileViewerModalProps) { const [workspaceFiles, setWorkspaceFiles] = useState([]); const [isLoadingFiles, setIsLoadingFiles] = useState(false); @@ -37,30 +40,72 @@ export function FileViewerModal({ const [fileType, setFileType] = useState<'text' | 'image' | 'pdf' | 'binary'>('text'); const [isLoadingContent, setIsLoadingContent] = useState(false); const fileInputRef = useRef(null); + + // Navigation state + const [currentPath, setCurrentPath] = useState("/workspace"); + const [pathHistory, setPathHistory] = useState(["/workspace"]); + const [historyIndex, setHistoryIndex] = useState(0); // Load files when the modal opens or sandbox ID changes useEffect(() => { if (open && sandboxId) { - loadWorkspaceFiles(); + loadFilesAtPath(currentPath); } - }, [open, sandboxId]); + }, [open, sandboxId, currentPath]); - // Function to load files from /workspace - const loadWorkspaceFiles = async () => { + // Function to load files from a specific path + const loadFilesAtPath = async (path: string) => { if (!sandboxId) return; setIsLoadingFiles(true); try { - const files = await listSandboxFiles(sandboxId, "/workspace"); + const files = await listSandboxFiles(sandboxId, path); setWorkspaceFiles(files); } catch (error) { - console.error("Failed to load workspace files:", error); - toast.error("Failed to load workspace files"); + console.error(`Failed to load files at ${path}:`, error); + toast.error("Failed to load files"); } finally { setIsLoadingFiles(false); } }; + // Navigate to a folder + const navigateToFolder = (folderPath: string) => { + // Update current path + setCurrentPath(folderPath); + + // Add to navigation history, discarding any forward history if we're not at the end + if (historyIndex < pathHistory.length - 1) { + setPathHistory(prevHistory => [...prevHistory.slice(0, historyIndex + 1), folderPath]); + setHistoryIndex(historyIndex + 1); + } else { + setPathHistory(prevHistory => [...prevHistory, folderPath]); + setHistoryIndex(pathHistory.length); + } + + // Reset file selection and content + setSelectedFile(null); + setFileContent(null); + setBinaryFileUrl(null); + }; + + // Go back in history + const goBack = () => { + if (historyIndex > 0) { + setHistoryIndex(historyIndex - 1); + setCurrentPath(pathHistory[historyIndex - 1]); + } + }; + + // Go to home directory + const goHome = () => { + setCurrentPath("/workspace"); + // Reset file selection and content + setSelectedFile(null); + setFileContent(null); + setBinaryFileUrl(null); + }; + // Determine file type based on extension const getFileType = (filename: string): 'text' | 'image' | 'pdf' | 'binary' => { const extension = filename.split('.').pop()?.toLowerCase() || ''; @@ -86,10 +131,15 @@ export function FileViewerModal({ return 'binary'; }; - // Handle file click to view content + // Handle file or folder click const handleFileClick = async (file: FileInfo) => { - if (file.is_dir) return; + if (file.is_dir) { + // If it's a directory, navigate to it + navigateToFolder(file.path); + return; + } + // Otherwise handle as regular file setSelectedFile(file.path); setIsLoadingContent(true); setFileContent(null); @@ -173,71 +223,106 @@ export function FileViewerModal({ } }; - // Process the file upload + // Process the file upload - upload to current directory const processFileUpload = async (event: React.ChangeEvent) => { if (!sandboxId || !event.target.files || event.target.files.length === 0) return; - const file = event.target.files[0]; - const fileType = getFileType(file.name); - const reader = new FileReader(); - - reader.onload = async (e) => { - if (!e.target?.result) return; + try { + setIsLoadingFiles(true); - try { - // For text files - let content: string; - if (typeof e.target.result === 'string') { - content = e.target.result; - } else { - // For binary files, convert to base64 - const buffer = e.target.result as ArrayBuffer; - content = btoa(String.fromCharCode(...new Uint8Array(buffer))); - } - - const filePath = `/workspace/${file.name}`; - await createSandboxFile(sandboxId, filePath, content); - toast.success(`File uploaded: ${file.name}`); - - // Refresh file list - loadWorkspaceFiles(); - } catch (error) { - console.error("File upload failed:", error); - toast.error("Failed to upload file"); + const file = event.target.files[0]; + + if (file.size > 50 * 1024 * 1024) { // 50MB limit + toast.error("File size exceeds 50MB limit"); + return; } - }; - - if (file.size > 10 * 1024 * 1024) { // 10MB limit - toast.error("File size exceeds 10MB limit"); - return; - } - - // Use different reader method based on file type - if (fileType === 'text') { - reader.readAsText(file); - } else { - reader.readAsArrayBuffer(file); - } - - // Reset the input - event.target.value = ''; - }; - - // Handle insert into message - const handleSelectFile = () => { - if (selectedFile && fileContent && onSelectFile && typeof fileContent === 'string') { - onSelectFile(selectedFile, fileContent); - onOpenChange(false); + + // Create a FormData object + const formData = new FormData(); + formData.append('file', file); + formData.append('path', `${currentPath}/${file.name}`); + + const supabase = createClient(); + const { data: { session } } = await supabase.auth.getSession(); + + if (!session?.access_token) { + throw new Error('No access token available'); + } + + // Upload using FormData - no need for any encoding/decoding + const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.access_token}`, + // Important: Do NOT set Content-Type header here, let the browser set it with the boundary + }, + body: formData + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.statusText}`); + } + + toast.success(`File uploaded: ${file.name}`); + + // Refresh file list for current path + loadFilesAtPath(currentPath); + } catch (error) { + console.error("File upload failed:", error); + toast.error(typeof error === 'string' ? error : (error instanceof Error ? error.message : "Failed to upload file")); + } finally { + setIsLoadingFiles(false); + // Reset the input + event.target.value = ''; } }; - // Copy file content to clipboard - const handleCopyContent = () => { - if (!fileContent) return; + // Render breadcrumb navigation + const renderBreadcrumbs = () => { + if (currentPath === "/workspace") { + return ( +
/workspace
+ ); + } - navigator.clipboard.writeText(fileContent) - .then(() => toast.success("File content copied to clipboard")) - .catch(() => toast.error("Failed to copy content")); + const parts = currentPath.split('/').filter(Boolean); + const isInWorkspace = parts[0] === 'workspace'; + const pathParts = isInWorkspace ? parts.slice(1) : parts; + + return ( +
+ + + {pathParts.map((part, index) => { + // Build the path up to this part + const pathUpToHere = isInWorkspace + ? `/workspace/${pathParts.slice(0, index + 1).join('/')}` + : `/${pathParts.slice(0, index + 1).join('/')}`; + + return ( +
+ + +
+ ); + })} +
+ ); }; // Render file content based on type @@ -263,9 +348,11 @@ export function FileViewerModal({ if (fileType === 'text' && fileContent) { return ( -
-          {fileContent}
-        
+
+
+            {fileContent}
+          
+
); } @@ -327,26 +414,36 @@ export function FileViewerModal({ {/* File browser sidebar */}
-

/workspace

-
+
+
+
+
+
+ {renderBreadcrumbs()} +
+
{isLoadingFiles ? (
@@ -366,8 +467,8 @@ export function FileViewerModal({
) : workspaceFiles.length === 0 ? (
- -

No files in workspace folder

+ +

This folder is empty

) : (
@@ -398,39 +499,16 @@ export function FileViewerModal({

{selectedFile ? selectedFile.split('/').pop() : 'Select a file to view'}

- {selectedFile && (fileContent || binaryFileUrl) && ( + {selectedFile && binaryFileUrl && (
- {fileType === 'text' && ( - - )} - {binaryFileUrl && ( - - - - )} - +
)}
@@ -438,14 +516,6 @@ export function FileViewerModal({
{renderFileContent()}
- - {onSelectFile && selectedFile && fileContent && ( -
- -
- )}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3fc0e2cf..58777fec 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -706,6 +706,11 @@ export const createSandboxFile = async (sandboxId: string, filePath: string, con throw new Error('No access token available'); } + // Determine if content is likely binary (contains non-printable characters) + const isProbablyBinary = /[\x00-\x08\x0E-\x1F\x80-\xFF]/.test(content) || + content.startsWith('data:') || + /^[A-Za-z0-9+/]*={0,2}$/.test(content); + const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, { method: 'POST', headers: { @@ -715,6 +720,7 @@ export const createSandboxFile = async (sandboxId: string, filePath: string, con body: JSON.stringify({ path: filePath, content: content, + is_base64: isProbablyBinary }), });