diff --git a/autogpt_platform/backend/backend/api/features/workspace/routes.py b/autogpt_platform/backend/backend/api/features/workspace/routes.py index 1495feeedf..9e2ee54e59 100644 --- a/autogpt_platform/backend/backend/api/features/workspace/routes.py +++ b/autogpt_platform/backend/backend/api/features/workspace/routes.py @@ -44,6 +44,18 @@ router = fastapi.APIRouter( ) +def _create_streaming_response(content: bytes, file) -> Response: + """Create a streaming response for file content.""" + return Response( + content=content, + media_type=file.mimeType, + headers={ + "Content-Disposition": _sanitize_filename_for_header(file.name), + "Content-Length": str(len(content)), + }, + ) + + async def _create_file_download_response(file) -> Response: """ Create a download response for a workspace file. @@ -56,14 +68,7 @@ async def _create_file_download_response(file) -> Response: # For local storage, stream the file directly if file.storagePath.startswith("local://"): content = await storage.retrieve(file.storagePath) - return Response( - content=content, - media_type=file.mimeType, - headers={ - "Content-Disposition": _sanitize_filename_for_header(file.name), - "Content-Length": str(len(content)), - }, - ) + return _create_streaming_response(content, file) # For GCS, try to redirect to signed URL, fall back to streaming try: @@ -71,26 +76,12 @@ async def _create_file_download_response(file) -> Response: # If we got back an API path (fallback), stream directly instead if url.startswith("/api/"): content = await storage.retrieve(file.storagePath) - return Response( - content=content, - media_type=file.mimeType, - headers={ - "Content-Disposition": _sanitize_filename_for_header(file.name), - "Content-Length": str(len(content)), - }, - ) + return _create_streaming_response(content, file) return fastapi.responses.RedirectResponse(url=url, status_code=302) except Exception: # Fall back to streaming directly from GCS content = await storage.retrieve(file.storagePath) - return Response( - content=content, - media_type=file.mimeType, - headers={ - "Content-Disposition": _sanitize_filename_for_header(file.name), - "Content-Length": str(len(content)), - }, - ) + return _create_streaming_response(content, file) @router.get( diff --git a/autogpt_platform/backend/backend/data/workspace.py b/autogpt_platform/backend/backend/data/workspace.py index a5791767fd..a354d3a3c3 100644 --- a/autogpt_platform/backend/backend/data/workspace.py +++ b/autogpt_platform/backend/backend/data/workspace.py @@ -235,6 +235,9 @@ async def soft_delete_workspace_file( """ Soft-delete a workspace file. + The path is modified to include a deletion timestamp to free up the original + path for new files while preserving the record for potential recovery. + Args: file_id: The file ID workspace_id: Optional workspace ID for validation @@ -247,11 +250,17 @@ async def soft_delete_workspace_file( if file is None: return None + deleted_at = datetime.now(timezone.utc) + # Modify path to free up the unique constraint for new files at original path + # Format: {original_path}__deleted__{timestamp} + deleted_path = f"{file.path}__deleted__{int(deleted_at.timestamp())}" + updated = await UserWorkspaceFile.prisma().update( where={"id": file_id}, data={ "isDeleted": True, - "deletedAt": datetime.now(timezone.utc), + "deletedAt": deleted_at, + "path": deleted_path, }, ) diff --git a/autogpt_platform/backend/backend/util/workspace.py b/autogpt_platform/backend/backend/util/workspace.py index 8789dc5a59..ce46a1e646 100644 --- a/autogpt_platform/backend/backend/util/workspace.py +++ b/autogpt_platform/backend/backend/util/workspace.py @@ -240,6 +240,11 @@ class WorkspaceManager: source_session_id=source_session_id, ) else: + # Clean up the orphaned storage file before raising + try: + await storage.delete(storage_path) + except Exception as e: + logger.warning(f"Failed to clean up orphaned storage file: {e}") raise ValueError(f"File already exists at path: {path}") logger.info( @@ -274,8 +279,11 @@ class WorkspaceManager: """ # Determine the effective path prefix if include_all_sessions: - # Use provided path as-is (or None for all files) - effective_path = path + # Normalize path to ensure leading slash (stored paths are normalized) + if path is not None and not path.startswith("/"): + effective_path = f"/{path}" + else: + effective_path = path elif path is not None: # Resolve the provided path with session scoping effective_path = self._resolve_path(path) @@ -389,8 +397,11 @@ class WorkspaceManager: """ # Determine the effective path prefix (same logic as list_files) if include_all_sessions: - # Use provided path as-is (or None for all files) - effective_path = path + # Normalize path to ensure leading slash (stored paths are normalized) + if path is not None and not path.startswith("/"): + effective_path = f"/{path}" + else: + effective_path = path elif path is not None: # Resolve the provided path with session scoping effective_path = self._resolve_path(path) diff --git a/autogpt_platform/backend/backend/util/workspace_storage.py b/autogpt_platform/backend/backend/util/workspace_storage.py index 7dcd7d3057..270820a018 100644 --- a/autogpt_platform/backend/backend/util/workspace_storage.py +++ b/autogpt_platform/backend/backend/util/workspace_storage.py @@ -86,19 +86,6 @@ class WorkspaceStorageBackend(ABC): """ pass - @abstractmethod - async def exists(self, storage_path: str) -> bool: - """ - Check if a file exists at the storage path. - - Args: - storage_path: The storage path to check - - Returns: - True if file exists, False otherwise - """ - pass - class GCSWorkspaceStorage(WorkspaceStorageBackend): """Google Cloud Storage implementation for workspace storage.""" @@ -265,25 +252,6 @@ class GCSWorkspaceStorage(WorkspaceStorageBackend): return f"/api/workspace/files/{file_id}/download" raise - async def exists(self, storage_path: str) -> bool: - """Check if file exists in GCS.""" - if not storage_path.startswith("gcs://"): - return False - - path = storage_path[6:] - parts = path.split("/", 1) - if len(parts) != 2: - return False - - bucket_name, blob_name = parts - - try: - client = await self._get_async_client() - await client.download_metadata(bucket_name, blob_name) - return True - except Exception: - return False - class LocalWorkspaceStorage(WorkspaceStorageBackend): """Local filesystem implementation for workspace storage (self-hosted deployments).""" @@ -410,14 +378,6 @@ class LocalWorkspaceStorage(WorkspaceStorageBackend): else: raise ValueError(f"Invalid storage path format: {storage_path}") - async def exists(self, storage_path: str) -> bool: - """Check if file exists locally.""" - try: - file_path = self._parse_storage_path(storage_path) - return file_path.exists() - except ValueError: - return False - # Global storage backend instance _workspace_storage: Optional[WorkspaceStorageBackend] = None diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MarkdownContent/MarkdownContent.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/MarkdownContent/MarkdownContent.tsx index 24c128c474..800ec4066c 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/MarkdownContent/MarkdownContent.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MarkdownContent/MarkdownContent.tsx @@ -38,6 +38,14 @@ interface InputProps extends React.InputHTMLAttributes { * Uses the generated API URL helper and routes through the Next.js proxy * which handles authentication and proper backend routing. */ +/** + * URL transformer for ReactMarkdown. + * Converts workspace:// URLs to proxy URLs that route through Next.js to the backend. + * workspace://abc123 -> /api/proxy/api/workspace/files/abc123/download + * + * This is needed because ReactMarkdown sanitizes URLs and only allows + * http, https, mailto, and tel protocols by default. + */ function resolveWorkspaceUrl(src: string): string { if (src.startsWith("workspace://")) { const fileId = src.replace("workspace://", ""); @@ -49,16 +57,6 @@ function resolveWorkspaceUrl(src: string): string { return src; } -/** - * URL transformer for ReactMarkdown. - * Transforms workspace:// URLs to backend API download URLs before rendering. - * This is needed because ReactMarkdown sanitizes URLs and only allows - * http, https, mailto, and tel protocols by default. - */ -function transformUrl(url: string): string { - return resolveWorkspaceUrl(url); -} - /** * Check if the image URL is a workspace file (AI cannot see these yet). * After URL transformation, workspace files have URLs like /api/proxy/api/workspace/files/... @@ -114,7 +112,7 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) { { const isInline = !className?.includes("language-");