diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000000..2bd5a0a98a36 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/autogpt_platform/backend/backend/api/features/chat/routes.py b/autogpt_platform/backend/backend/api/features/chat/routes.py index 9735f30edb41..54ecda969535 100644 --- a/autogpt_platform/backend/backend/api/features/chat/routes.py +++ b/autogpt_platform/backend/backend/api/features/chat/routes.py @@ -2,6 +2,7 @@ import asyncio import logging +import re from collections.abc import AsyncGenerator from typing import Annotated from uuid import uuid4 @@ -9,7 +10,8 @@ from autogpt_libs import auth from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security from fastapi.responses import StreamingResponse -from pydantic import BaseModel +from prisma.models import UserWorkspaceFile +from pydantic import BaseModel, Field from backend.copilot import service as chat_service from backend.copilot import stream_registry @@ -47,10 +49,14 @@ UnderstandingUpdatedResponse, ) from backend.copilot.tracking import track_user_message +from backend.data.workspace import get_or_create_workspace from backend.util.exceptions import NotFoundError config = ChatConfig() +_UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I +) logger = logging.getLogger(__name__) @@ -79,6 +85,9 @@ class StreamChatRequest(BaseModel): message: str is_user_message: bool = True context: dict[str, str] | None = None # {url: str, content: str} + file_ids: list[str] | None = Field( + default=None, max_length=20 + ) # Workspace file IDs attached to this message class CreateSessionResponse(BaseModel): @@ -394,6 +403,38 @@ async def stream_chat_post( }, ) + # Enrich message with file metadata if file_ids are provided. + # Also sanitise file_ids so only validated, workspace-scoped IDs are + # forwarded downstream (e.g. to the executor via enqueue_copilot_turn). + sanitized_file_ids: list[str] | None = None + if request.file_ids and user_id: + # Filter to valid UUIDs only to prevent DB abuse + valid_ids = [fid for fid in request.file_ids if _UUID_RE.match(fid)] + + if valid_ids: + workspace = await get_or_create_workspace(user_id) + # Batch query instead of N+1 + files = await UserWorkspaceFile.prisma().find_many( + where={ + "id": {"in": valid_ids}, + "workspaceId": workspace.id, + "isDeleted": False, + } + ) + # Only keep IDs that actually exist in the user's workspace + sanitized_file_ids = [wf.id for wf in files] or None + file_lines: list[str] = [ + f"- {wf.name} ({wf.mimeType}, {round(wf.sizeBytes / 1024, 1)} KB), file_id={wf.id}" + for wf in files + ] + if file_lines: + files_block = ( + "\n\n[Attached files]\n" + + "\n".join(file_lines) + + "\nUse read_workspace_file with the file_id to access file contents." + ) + request.message += files_block + # Atomically append user message to session BEFORE creating task to avoid # race condition where GET_SESSION sees task as "running" but message isn't # saved yet. append_and_save_message re-fetches inside a lock to prevent @@ -445,6 +486,7 @@ async def stream_chat_post( turn_id=turn_id, is_user_message=request.is_user_message, context=request.context, + file_ids=sanitized_file_ids, ) setup_time = (time.perf_counter() - stream_start_time) * 1000 diff --git a/autogpt_platform/backend/backend/api/features/chat/routes_test.py b/autogpt_platform/backend/backend/api/features/chat/routes_test.py new file mode 100644 index 000000000000..190e0581cf95 --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/chat/routes_test.py @@ -0,0 +1,160 @@ +"""Tests for chat route file_ids validation and enrichment.""" + +import fastapi +import fastapi.testclient +import pytest +import pytest_mock + +from backend.api.features.chat import routes as chat_routes + +app = fastapi.FastAPI() +app.include_router(chat_routes.router) + +client = fastapi.testclient.TestClient(app) + +TEST_USER_ID = "3e53486c-cf57-477e-ba2a-cb02dc828e1a" + + +@pytest.fixture(autouse=True) +def setup_app_auth(mock_jwt_user): + from autogpt_libs.auth.jwt_utils import get_jwt_payload + + app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"] + yield + app.dependency_overrides.clear() + + +# ---- file_ids Pydantic validation (B1) ---- + + +def test_stream_chat_rejects_too_many_file_ids(): + """More than 20 file_ids should be rejected by Pydantic validation (422).""" + response = client.post( + "/sessions/sess-1/stream", + json={ + "message": "hello", + "file_ids": [f"00000000-0000-0000-0000-{i:012d}" for i in range(21)], + }, + ) + assert response.status_code == 422 + + +def _mock_stream_internals(mocker: pytest_mock.MockFixture): + """Mock the async internals of stream_chat_post so tests can exercise + validation and enrichment logic without needing Redis/RabbitMQ.""" + mocker.patch( + "backend.api.features.chat.routes._validate_and_get_session", + return_value=None, + ) + mocker.patch( + "backend.api.features.chat.routes.append_and_save_message", + return_value=None, + ) + mock_registry = mocker.MagicMock() + mock_registry.create_session = mocker.AsyncMock(return_value=None) + mocker.patch( + "backend.api.features.chat.routes.stream_registry", + mock_registry, + ) + mocker.patch( + "backend.api.features.chat.routes.enqueue_copilot_turn", + return_value=None, + ) + mocker.patch( + "backend.api.features.chat.routes.track_user_message", + return_value=None, + ) + + +def test_stream_chat_accepts_20_file_ids(mocker: pytest_mock.MockFixture): + """Exactly 20 file_ids should be accepted (not rejected by validation).""" + _mock_stream_internals(mocker) + # Patch workspace lookup as imported by the routes module + mocker.patch( + "backend.api.features.chat.routes.get_or_create_workspace", + return_value=type("W", (), {"id": "ws-1"})(), + ) + mock_prisma = mocker.MagicMock() + mock_prisma.find_many = mocker.AsyncMock(return_value=[]) + mocker.patch( + "prisma.models.UserWorkspaceFile.prisma", + return_value=mock_prisma, + ) + + response = client.post( + "/sessions/sess-1/stream", + json={ + "message": "hello", + "file_ids": [f"00000000-0000-0000-0000-{i:012d}" for i in range(20)], + }, + ) + # Should get past validation — 200 streaming response expected + assert response.status_code == 200 + + +# ---- UUID format filtering ---- + + +def test_file_ids_filters_invalid_uuids(mocker: pytest_mock.MockFixture): + """Non-UUID strings in file_ids should be silently filtered out + and NOT passed to the database query.""" + _mock_stream_internals(mocker) + mocker.patch( + "backend.api.features.chat.routes.get_or_create_workspace", + return_value=type("W", (), {"id": "ws-1"})(), + ) + + mock_prisma = mocker.MagicMock() + mock_prisma.find_many = mocker.AsyncMock(return_value=[]) + mocker.patch( + "prisma.models.UserWorkspaceFile.prisma", + return_value=mock_prisma, + ) + + valid_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + client.post( + "/sessions/sess-1/stream", + json={ + "message": "hello", + "file_ids": [ + valid_id, + "not-a-uuid", + "../../../etc/passwd", + "", + ], + }, + ) + + # The find_many call should only receive the one valid UUID + mock_prisma.find_many.assert_called_once() + call_kwargs = mock_prisma.find_many.call_args[1] + assert call_kwargs["where"]["id"]["in"] == [valid_id] + + +# ---- Cross-workspace file_ids ---- + + +def test_file_ids_scoped_to_workspace(mocker: pytest_mock.MockFixture): + """The batch query should scope to the user's workspace.""" + _mock_stream_internals(mocker) + mocker.patch( + "backend.api.features.chat.routes.get_or_create_workspace", + return_value=type("W", (), {"id": "my-workspace-id"})(), + ) + + mock_prisma = mocker.MagicMock() + mock_prisma.find_many = mocker.AsyncMock(return_value=[]) + mocker.patch( + "prisma.models.UserWorkspaceFile.prisma", + return_value=mock_prisma, + ) + + fid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + client.post( + "/sessions/sess-1/stream", + json={"message": "hi", "file_ids": [fid]}, + ) + + call_kwargs = mock_prisma.find_many.call_args[1] + assert call_kwargs["where"]["workspaceId"] == "my-workspace-id" + assert call_kwargs["where"]["isDeleted"] is False diff --git a/autogpt_platform/backend/backend/api/features/workspace/routes.py b/autogpt_platform/backend/backend/api/features/workspace/routes.py index 974465b2c055..588b88b64b37 100644 --- a/autogpt_platform/backend/backend/api/features/workspace/routes.py +++ b/autogpt_platform/backend/backend/api/features/workspace/routes.py @@ -3,15 +3,29 @@ """ import logging +import os import re from typing import Annotated from urllib.parse import quote import fastapi from autogpt_libs.auth.dependencies import get_user_id, requires_user +from fastapi import Query, UploadFile from fastapi.responses import Response +from pydantic import BaseModel -from backend.data.workspace import WorkspaceFile, get_workspace, get_workspace_file +from backend.data.workspace import ( + WorkspaceFile, + count_workspace_files, + get_or_create_workspace, + get_workspace, + get_workspace_file, + get_workspace_total_size, + soft_delete_workspace_file, +) +from backend.util.settings import Config +from backend.util.virus_scanner import scan_content_safe +from backend.util.workspace import WorkspaceManager from backend.util.workspace_storage import get_workspace_storage @@ -98,6 +112,21 @@ async def _create_file_download_response(file: WorkspaceFile) -> Response: raise +class UploadFileResponse(BaseModel): + file_id: str + name: str + path: str + mime_type: str + size_bytes: int + + +class StorageUsageResponse(BaseModel): + used_bytes: int + limit_bytes: int + used_percent: float + file_count: int + + @router.get( "/files/{file_id}/download", summary="Download file by ID", @@ -120,3 +149,120 @@ async def download_file( raise fastapi.HTTPException(status_code=404, detail="File not found") return await _create_file_download_response(file) + + +@router.post( + "/files/upload", + summary="Upload file to workspace", +) +async def upload_file( + user_id: Annotated[str, fastapi.Security(get_user_id)], + file: UploadFile, + session_id: str | None = Query(default=None), +) -> UploadFileResponse: + """ + Upload a file to the user's workspace. + + Files are stored in session-scoped paths when session_id is provided, + so the agent's session-scoped tools can discover them automatically. + """ + config = Config() + + # Sanitize filename — strip any directory components + filename = os.path.basename(file.filename or "upload") or "upload" + + # Read file content with early abort on size limit + max_file_bytes = config.max_file_size_mb * 1024 * 1024 + chunks: list[bytes] = [] + total_size = 0 + while chunk := await file.read(64 * 1024): # 64KB chunks + total_size += len(chunk) + if total_size > max_file_bytes: + raise fastapi.HTTPException( + status_code=413, + detail=f"File exceeds maximum size of {config.max_file_size_mb} MB", + ) + chunks.append(chunk) + content = b"".join(chunks) + + # Get or create workspace + workspace = await get_or_create_workspace(user_id) + + # Pre-write storage cap check (soft check — final enforcement is post-write) + storage_limit_bytes = config.max_workspace_storage_mb * 1024 * 1024 + current_usage = await get_workspace_total_size(workspace.id) + if storage_limit_bytes and current_usage + len(content) > storage_limit_bytes: + used_percent = (current_usage / storage_limit_bytes) * 100 + raise fastapi.HTTPException( + status_code=413, + detail={ + "message": "Storage limit exceeded", + "used_bytes": current_usage, + "limit_bytes": storage_limit_bytes, + "used_percent": round(used_percent, 1), + }, + ) + + # Warn at 80% usage + if ( + storage_limit_bytes + and (usage_ratio := (current_usage + len(content)) / storage_limit_bytes) >= 0.8 + ): + logger.warning( + f"User {user_id} workspace storage at {usage_ratio * 100:.1f}% " + f"({current_usage + len(content)} / {storage_limit_bytes} bytes)" + ) + + # Virus scan + await scan_content_safe(content, filename=filename) + + # Write file via WorkspaceManager + manager = WorkspaceManager(user_id, workspace.id, session_id) + workspace_file = await manager.write_file(content, filename) + + # Post-write storage check — eliminates TOCTOU race on the quota. + # If a concurrent upload pushed us over the limit, undo this write. + new_total = await get_workspace_total_size(workspace.id) + if storage_limit_bytes and new_total > storage_limit_bytes: + await soft_delete_workspace_file(workspace_file.id, workspace.id) + raise fastapi.HTTPException( + status_code=413, + detail={ + "message": "Storage limit exceeded (concurrent upload)", + "used_bytes": new_total, + "limit_bytes": storage_limit_bytes, + }, + ) + + return UploadFileResponse( + file_id=workspace_file.id, + name=workspace_file.name, + path=workspace_file.path, + mime_type=workspace_file.mime_type, + size_bytes=workspace_file.size_bytes, + ) + + +@router.get( + "/storage/usage", + summary="Get workspace storage usage", +) +async def get_storage_usage( + user_id: Annotated[str, fastapi.Security(get_user_id)], +) -> StorageUsageResponse: + """ + Get storage usage information for the user's workspace. + """ + config = Config() + workspace = await get_or_create_workspace(user_id) + + used_bytes = await get_workspace_total_size(workspace.id) + file_count = await count_workspace_files(workspace.id) + limit_bytes = config.max_workspace_storage_mb * 1024 * 1024 + + return StorageUsageResponse( + used_bytes=used_bytes, + limit_bytes=limit_bytes, + used_percent=round((used_bytes / limit_bytes) * 100, 1) if limit_bytes else 0, + file_count=file_count, + ) diff --git a/autogpt_platform/backend/backend/api/features/workspace/routes_test.py b/autogpt_platform/backend/backend/api/features/workspace/routes_test.py new file mode 100644 index 000000000000..e87ea98db23e --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/workspace/routes_test.py @@ -0,0 +1,307 @@ +"""Tests for workspace file upload and download routes.""" + +import io +from datetime import datetime, timezone + +import fastapi +import fastapi.testclient +import pytest +import pytest_mock + +from backend.api.features.workspace import routes as workspace_routes +from backend.data.workspace import WorkspaceFile + +app = fastapi.FastAPI() +app.include_router(workspace_routes.router) + + +@app.exception_handler(ValueError) +async def _value_error_handler( + request: fastapi.Request, exc: ValueError +) -> fastapi.responses.JSONResponse: + """Mirror the production ValueError → 400 mapping from rest_api.py.""" + return fastapi.responses.JSONResponse(status_code=400, content={"detail": str(exc)}) + + +client = fastapi.testclient.TestClient(app) + +TEST_USER_ID = "3e53486c-cf57-477e-ba2a-cb02dc828e1a" + +MOCK_WORKSPACE = type("W", (), {"id": "ws-1"})() + +_NOW = datetime(2023, 1, 1, tzinfo=timezone.utc) + +MOCK_FILE = WorkspaceFile( + id="file-aaa-bbb", + workspace_id="ws-1", + created_at=_NOW, + updated_at=_NOW, + name="hello.txt", + path="/session/hello.txt", + mime_type="text/plain", + size_bytes=13, + storage_path="local://hello.txt", +) + + +@pytest.fixture(autouse=True) +def setup_app_auth(mock_jwt_user): + from autogpt_libs.auth.jwt_utils import get_jwt_payload + + app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"] + yield + app.dependency_overrides.clear() + + +def _upload( + filename: str = "hello.txt", + content: bytes = b"Hello, world!", + content_type: str = "text/plain", +): + """Helper to POST a file upload.""" + return client.post( + "/files/upload?session_id=sess-1", + files={"file": (filename, io.BytesIO(content), content_type)}, + ) + + +# ---- Happy path ---- + + +def test_upload_happy_path(mocker: pytest_mock.MockFixture): + mocker.patch( + "backend.api.features.workspace.routes.get_or_create_workspace", + return_value=MOCK_WORKSPACE, + ) + mocker.patch( + "backend.api.features.workspace.routes.get_workspace_total_size", + return_value=0, + ) + mocker.patch( + "backend.api.features.workspace.routes.scan_content_safe", + return_value=None, + ) + mock_manager = mocker.MagicMock() + mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mocker.patch( + "backend.api.features.workspace.routes.WorkspaceManager", + return_value=mock_manager, + ) + + response = _upload() + assert response.status_code == 200 + data = response.json() + assert data["file_id"] == "file-aaa-bbb" + assert data["name"] == "hello.txt" + assert data["size_bytes"] == 13 + + +# ---- Per-file size limit ---- + + +def test_upload_exceeds_max_file_size(mocker: pytest_mock.MockFixture): + """Files larger than max_file_size_mb should be rejected with 413.""" + cfg = mocker.patch("backend.api.features.workspace.routes.Config") + cfg.return_value.max_file_size_mb = 0 # 0 MB → any content is too big + cfg.return_value.max_workspace_storage_mb = 500 + + response = _upload(content=b"x" * 1024) + assert response.status_code == 413 + + +# ---- Storage quota exceeded ---- + + +def test_upload_storage_quota_exceeded(mocker: pytest_mock.MockFixture): + mocker.patch( + "backend.api.features.workspace.routes.get_or_create_workspace", + return_value=MOCK_WORKSPACE, + ) + # Current usage already at limit + mocker.patch( + "backend.api.features.workspace.routes.get_workspace_total_size", + return_value=500 * 1024 * 1024, + ) + + response = _upload() + assert response.status_code == 413 + assert "Storage limit exceeded" in response.text + + +# ---- Post-write quota race (B2) ---- + + +def test_upload_post_write_quota_race(mocker: pytest_mock.MockFixture): + """If a concurrent upload tips the total over the limit after write, + the file should be soft-deleted and 413 returned.""" + mocker.patch( + "backend.api.features.workspace.routes.get_or_create_workspace", + return_value=MOCK_WORKSPACE, + ) + # Pre-write check passes (under limit), but post-write check fails + mocker.patch( + "backend.api.features.workspace.routes.get_workspace_total_size", + side_effect=[0, 600 * 1024 * 1024], # first call OK, second over limit + ) + mocker.patch( + "backend.api.features.workspace.routes.scan_content_safe", + return_value=None, + ) + mock_manager = mocker.MagicMock() + mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mocker.patch( + "backend.api.features.workspace.routes.WorkspaceManager", + return_value=mock_manager, + ) + mock_delete = mocker.patch( + "backend.api.features.workspace.routes.soft_delete_workspace_file", + return_value=None, + ) + + response = _upload() + assert response.status_code == 413 + mock_delete.assert_called_once_with("file-aaa-bbb", "ws-1") + + +# ---- Any extension accepted (no allowlist) ---- + + +def test_upload_any_extension(mocker: pytest_mock.MockFixture): + """Any file extension should be accepted — ClamAV is the security layer.""" + mocker.patch( + "backend.api.features.workspace.routes.get_or_create_workspace", + return_value=MOCK_WORKSPACE, + ) + mocker.patch( + "backend.api.features.workspace.routes.get_workspace_total_size", + return_value=0, + ) + mocker.patch( + "backend.api.features.workspace.routes.scan_content_safe", + return_value=None, + ) + mock_manager = mocker.MagicMock() + mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mocker.patch( + "backend.api.features.workspace.routes.WorkspaceManager", + return_value=mock_manager, + ) + + response = _upload(filename="data.xyz", content=b"arbitrary") + assert response.status_code == 200 + + +# ---- Virus scan rejection ---- + + +def test_upload_blocked_by_virus_scan(mocker: pytest_mock.MockFixture): + """Files flagged by ClamAV should be rejected and never written to storage.""" + from backend.api.features.store.exceptions import VirusDetectedError + + mocker.patch( + "backend.api.features.workspace.routes.get_or_create_workspace", + return_value=MOCK_WORKSPACE, + ) + mocker.patch( + "backend.api.features.workspace.routes.get_workspace_total_size", + return_value=0, + ) + mocker.patch( + "backend.api.features.workspace.routes.scan_content_safe", + side_effect=VirusDetectedError("Eicar-Test-Signature"), + ) + mock_manager = mocker.MagicMock() + mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mocker.patch( + "backend.api.features.workspace.routes.WorkspaceManager", + return_value=mock_manager, + ) + + response = _upload(filename="evil.exe", content=b"X5O!P%@AP...") + assert response.status_code == 400 + assert "Virus detected" in response.text + mock_manager.write_file.assert_not_called() + + +# ---- No file extension ---- + + +def test_upload_file_without_extension(mocker: pytest_mock.MockFixture): + """Files without an extension should be accepted and stored as-is.""" + mocker.patch( + "backend.api.features.workspace.routes.get_or_create_workspace", + return_value=MOCK_WORKSPACE, + ) + mocker.patch( + "backend.api.features.workspace.routes.get_workspace_total_size", + return_value=0, + ) + mocker.patch( + "backend.api.features.workspace.routes.scan_content_safe", + return_value=None, + ) + mock_manager = mocker.MagicMock() + mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mocker.patch( + "backend.api.features.workspace.routes.WorkspaceManager", + return_value=mock_manager, + ) + + response = _upload( + filename="Makefile", + content=b"all:\n\techo hello", + content_type="application/octet-stream", + ) + assert response.status_code == 200 + mock_manager.write_file.assert_called_once() + assert mock_manager.write_file.call_args[0][1] == "Makefile" + + +# ---- Filename sanitization (SF5) ---- + + +def test_upload_strips_path_components(mocker: pytest_mock.MockFixture): + """Path-traversal filenames should be reduced to their basename.""" + mocker.patch( + "backend.api.features.workspace.routes.get_or_create_workspace", + return_value=MOCK_WORKSPACE, + ) + mocker.patch( + "backend.api.features.workspace.routes.get_workspace_total_size", + return_value=0, + ) + mocker.patch( + "backend.api.features.workspace.routes.scan_content_safe", + return_value=None, + ) + mock_manager = mocker.MagicMock() + mock_manager.write_file = mocker.AsyncMock(return_value=MOCK_FILE) + mocker.patch( + "backend.api.features.workspace.routes.WorkspaceManager", + return_value=mock_manager, + ) + + # Filename with traversal + _upload(filename="../../etc/passwd.txt") + + # write_file should have been called with just the basename + mock_manager.write_file.assert_called_once() + call_args = mock_manager.write_file.call_args + assert call_args[0][1] == "passwd.txt" + + +# ---- Download ---- + + +def test_download_file_not_found(mocker: pytest_mock.MockFixture): + mocker.patch( + "backend.api.features.workspace.routes.get_workspace", + return_value=MOCK_WORKSPACE, + ) + mocker.patch( + "backend.api.features.workspace.routes.get_workspace_file", + return_value=None, + ) + + response = client.get("/files/some-file-id/download") + assert response.status_code == 404 diff --git a/autogpt_platform/backend/backend/copilot/executor/processor.py b/autogpt_platform/backend/backend/copilot/executor/processor.py index 38a1b4a85182..c5ff8c0ae194 100644 --- a/autogpt_platform/backend/backend/copilot/executor/processor.py +++ b/autogpt_platform/backend/backend/copilot/executor/processor.py @@ -119,12 +119,12 @@ def cleanup(self): """ from backend.util.workspace_storage import shutdown_workspace_storage + coro = shutdown_workspace_storage() try: - future = asyncio.run_coroutine_threadsafe( - shutdown_workspace_storage(), self.execution_loop - ) + future = asyncio.run_coroutine_threadsafe(coro, self.execution_loop) future.result(timeout=5) except Exception as e: + coro.close() # Prevent "coroutine was never awaited" warning error_msg = str(e) or type(e).__name__ logger.warning( f"[CoPilotExecutor] Worker {self.tid} cleanup error: {error_msg}" diff --git a/autogpt_platform/backend/backend/copilot/executor/utils.py b/autogpt_platform/backend/backend/copilot/executor/utils.py index 017eea0e6e0e..5f75ccddca69 100644 --- a/autogpt_platform/backend/backend/copilot/executor/utils.py +++ b/autogpt_platform/backend/backend/copilot/executor/utils.py @@ -153,6 +153,9 @@ class CoPilotExecutionEntry(BaseModel): context: dict[str, str] | None = None """Optional context for the message (e.g., {url: str, content: str})""" + file_ids: list[str] | None = None + """Workspace file IDs attached to the user's message""" + class CancelCoPilotEvent(BaseModel): """Event to cancel a CoPilot operation.""" @@ -171,6 +174,7 @@ async def enqueue_copilot_turn( turn_id: str, is_user_message: bool = True, context: dict[str, str] | None = None, + file_ids: list[str] | None = None, ) -> None: """Enqueue a CoPilot task for processing by the executor service. @@ -181,6 +185,7 @@ async def enqueue_copilot_turn( turn_id: Per-turn UUID for Redis stream isolation is_user_message: Whether the message is from the user (vs system/assistant) context: Optional context for the message (e.g., {url: str, content: str}) + file_ids: Optional workspace file IDs attached to the user's message """ from backend.util.clients import get_async_copilot_queue @@ -191,6 +196,7 @@ async def enqueue_copilot_turn( message=message, is_user_message=is_user_message, context=context, + file_ids=file_ids, ) queue_client = await get_async_copilot_queue() diff --git a/autogpt_platform/backend/backend/data/workspace.py b/autogpt_platform/backend/backend/data/workspace.py index b368ea86b90d..5f5a6e076a21 100644 --- a/autogpt_platform/backend/backend/data/workspace.py +++ b/autogpt_platform/backend/backend/data/workspace.py @@ -327,11 +327,16 @@ async def get_workspace_total_size(workspace_id: str) -> int: """ Get the total size of all files in a workspace. + Queries Prisma directly (skipping Pydantic model conversion) and only + fetches the ``sizeBytes`` column to minimise data transfer. + Args: workspace_id: The workspace ID Returns: Total size in bytes """ - files = await list_workspace_files(workspace_id) - return sum(file.size_bytes for file in files) + files = await UserWorkspaceFile.prisma().find_many( + where={"workspaceId": workspace_id, "isDeleted": False}, + ) + return sum(f.sizeBytes for f in files) diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index 618bfbd13acb..35cf71c8879a 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -413,6 +413,13 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): description="Maximum file size in MB for workspace files (1-1024 MB)", ) + max_workspace_storage_mb: int = Field( + default=500, + ge=1, + le=10240, + description="Maximum total workspace storage per user in MB.", + ) + # AutoMod configuration automod_enabled: bool = Field( default=False, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx index b14f6f67e76a..bb2fce2d16cc 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx @@ -7,7 +7,9 @@ import { DropdownMenuTrigger, } from "@/components/molecules/DropdownMenu/DropdownMenu"; import { SidebarProvider } from "@/components/ui/sidebar"; -import { DotsThree } from "@phosphor-icons/react"; +import { cn } from "@/lib/utils"; +import { DotsThree, UploadSimple } from "@phosphor-icons/react"; +import { useCallback, useRef, useState } from "react"; import { ChatContainer } from "./components/ChatContainer/ChatContainer"; import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar"; import { DeleteChatDialog } from "./components/DeleteChatDialog/DeleteChatDialog"; @@ -17,6 +19,49 @@ import { ScaleLoader } from "./components/ScaleLoader/ScaleLoader"; import { useCopilotPage } from "./useCopilotPage"; export function CopilotPage() { + const [isDragging, setIsDragging] = useState(false); + const [droppedFiles, setDroppedFiles] = useState([]); + const dragCounter = useRef(0); + + const handleDroppedFilesConsumed = useCallback(() => { + setDroppedFiles([]); + }, []); + + function handleDragEnter(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current += 1; + if (e.dataTransfer.types.includes("Files")) { + setIsDragging(true); + } + } + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + } + + function handleDragLeave(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current -= 1; + if (dragCounter.current === 0) { + setIsDragging(false); + } + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current = 0; + setIsDragging(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + setDroppedFiles(files); + } + } + const { sessionId, messages, @@ -29,6 +74,7 @@ export function CopilotPage() { isLoadingSession, isSessionError, isCreatingSession, + isUploadingFiles, isUserLoading, isLoggedIn, // Mobile drawer @@ -63,8 +109,26 @@ export function CopilotPage() { className="h-[calc(100vh-72px)] min-h-0" > {!isMobile && } -
+
{isMobile && } + {/* Drop overlay */} +
+ + + Drop files here + +
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx index 8e946ccae854..3ec761183a51 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx @@ -18,9 +18,14 @@ export interface ChatContainerProps { /** True when backend has an active stream but we haven't reconnected yet. */ isReconnecting?: boolean; onCreateSession: () => void | Promise; - onSend: (message: string) => void | Promise; + onSend: (message: string, files?: File[]) => void | Promise; onStop: () => void; + isUploadingFiles?: boolean; headerSlot?: ReactNode; + /** Files dropped onto the chat window. */ + droppedFiles?: File[]; + /** Called after droppedFiles have been consumed by ChatInput. */ + onDroppedFilesConsumed?: () => void; } export const ChatContainer = ({ messages, @@ -34,7 +39,10 @@ export const ChatContainer = ({ onCreateSession, onSend, onStop, + isUploadingFiles, headerSlot, + droppedFiles, + onDroppedFilesConsumed, }: ChatContainerProps) => { const isBusy = status === "streaming" || @@ -69,8 +77,11 @@ export const ChatContainer = ({ onSend={onSend} disabled={isBusy} isStreaming={isBusy} + isUploadingFiles={isUploadingFiles} onStop={onStop} placeholder="What else can I help with?" + droppedFiles={droppedFiles} + onDroppedFilesConsumed={onDroppedFilesConsumed} />
@@ -80,6 +91,9 @@ export const ChatContainer = ({ isCreatingSession={isCreatingSession} onCreateSession={onCreateSession} onSend={onSend} + isUploadingFiles={isUploadingFiles} + droppedFiles={droppedFiles} + onDroppedFilesConsumed={onDroppedFilesConsumed} /> )}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/ChatInput.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/ChatInput.tsx index 938edfa9927c..cec9d98e7182 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/ChatInput.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/ChatInput.tsx @@ -9,38 +9,66 @@ import { import { InputGroup } from "@/components/ui/input-group"; import { cn } from "@/lib/utils"; import { CircleNotchIcon, MicrophoneIcon } from "@phosphor-icons/react"; -import { ChangeEvent } from "react"; +import { ChangeEvent, useEffect, useState } from "react"; +import { AttachmentMenu } from "./components/AttachmentMenu"; +import { FileChips } from "./components/FileChips"; import { RecordingIndicator } from "./components/RecordingIndicator"; import { useChatInput } from "./useChatInput"; import { useVoiceRecording } from "./useVoiceRecording"; export interface Props { - onSend: (message: string) => void | Promise; + onSend: (message: string, files?: File[]) => void | Promise; disabled?: boolean; isStreaming?: boolean; + isUploadingFiles?: boolean; onStop?: () => void; placeholder?: string; className?: string; inputId?: string; + /** Files dropped onto the chat window by the parent. */ + droppedFiles?: File[]; + /** Called after droppedFiles have been merged into internal state. */ + onDroppedFilesConsumed?: () => void; } export function ChatInput({ onSend, disabled = false, isStreaming = false, + isUploadingFiles = false, onStop, placeholder = "Type your message...", className, inputId = "chat-input", + droppedFiles, + onDroppedFilesConsumed, }: Props) { + const [files, setFiles] = useState([]); + + // Merge files dropped onto the chat window into internal state. + useEffect(() => { + if (droppedFiles && droppedFiles.length > 0) { + setFiles((prev) => [...prev, ...droppedFiles]); + onDroppedFilesConsumed?.(); + } + }, [droppedFiles, onDroppedFilesConsumed]); + + const hasFiles = files.length > 0; + const isBusy = disabled || isStreaming || isUploadingFiles; + const { value, setValue, handleSubmit, handleChange: baseHandleChange, } = useChatInput({ - onSend, - disabled: disabled || isStreaming, + onSend: async (message: string) => { + await onSend(message, hasFiles ? files : undefined); + // Only clear files after successful send (onSend throws on failure) + setFiles([]); + }, + disabled: isBusy, + canSendEmpty: hasFiles, inputId, }); @@ -55,7 +83,7 @@ export function ChatInput({ audioStream, } = useVoiceRecording({ setValue, - disabled: disabled || isStreaming, + disabled: isBusy, isStreaming, value, inputId, @@ -67,7 +95,18 @@ export function ChatInput({ } const canSend = - !disabled && !!value.trim() && !isRecording && !isTranscribing; + !disabled && + (!!value.trim() || hasFiles) && + !isRecording && + !isTranscribing; + + function handleFilesSelected(newFiles: File[]) { + setFiles((prev) => [...prev, ...newFiles]); + } + + function handleRemoveFile(index: number) { + setFiles((prev) => prev.filter((_, i) => i !== index)); + } return (
@@ -78,6 +117,11 @@ export function ChatInput({ "border-red-400 ring-1 ring-red-400 has-[[data-slot=input-group-control]:focus-visible]:border-red-400 has-[[data-slot=input-group-control]:focus-visible]:ring-red-400", )} > + + {showMicButton && ( void; + disabled?: boolean; +} + +export function AttachmentMenu({ onFilesSelected, disabled }: Props) { + const fileInputRef = useRef(null); + + function handleClick() { + fileInputRef.current?.click(); + } + + function handleFileChange(e: React.ChangeEvent) { + const files = Array.from(e.target.files ?? []); + if (files.length > 0) { + onFilesSelected(files); + } + // Reset so the same file can be re-selected + e.target.value = ""; + } + + return ( + <> + + + + ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/components/FileChips.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/components/FileChips.tsx new file mode 100644 index 000000000000..1685412d562a --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/components/FileChips.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { + CircleNotch as CircleNotchIcon, + X as XIcon, +} from "@phosphor-icons/react"; + +interface Props { + files: File[]; + onRemove: (index: number) => void; + isUploading?: boolean; +} + +export function FileChips({ files, onRemove, isUploading }: Props) { + if (files.length === 0) return null; + + return ( +
+ {files.map((file, index) => ( + + {file.name} + {isUploading ? ( + + ) : ( + + )} + + ))} +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/useChatInput.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/useChatInput.ts index 8394b028bbb5..28ebb38ced39 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/useChatInput.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/useChatInput.ts @@ -3,12 +3,15 @@ import { ChangeEvent, FormEvent, useEffect, useState } from "react"; interface Args { onSend: (message: string) => void; disabled?: boolean; + /** Allow sending when text is empty (e.g. when files are attached). */ + canSendEmpty?: boolean; inputId?: string; } export function useChatInput({ onSend, disabled = false, + canSendEmpty = false, inputId = "chat-input", }: Args) { const [value, setValue] = useState(""); @@ -32,7 +35,7 @@ export function useChatInput({ ); async function handleSend() { - if (disabled || isSending || !value.trim()) return; + if (disabled || isSending || (!value.trim() && !canSendEmpty)) return; setIsSending(true); try { diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx index 7362578b3ed1..419f4d611b6d 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx @@ -5,7 +5,8 @@ import { } from "@/components/ai-elements/conversation"; import { Message, MessageContent } from "@/components/ai-elements/message"; import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; -import { UIDataTypes, UIMessage, UITools } from "ai"; +import { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai"; +import { MessageAttachments } from "./components/MessageAttachments"; import { MessagePartRenderer } from "./components/MessagePartRenderer"; import { ThinkingIndicator } from "./components/ThinkingIndicator"; @@ -72,6 +73,10 @@ export function ChatMessagesContainer({ messageIndex === messages.length - 1 && message.role === "assistant"; + const fileParts = message.parts.filter( + (p): p is FileUIPart => p.type === "file", + ); + return ( )} + {fileParts.length > 0 && ( + + )} ); })} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/MessageAttachments.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/MessageAttachments.tsx new file mode 100644 index 000000000000..1568da54cd5f --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/MessageAttachments.tsx @@ -0,0 +1,84 @@ +import { + FileText as FileTextIcon, + DownloadSimple as DownloadIcon, +} from "@phosphor-icons/react"; +import type { FileUIPart } from "ai"; +import { + ContentCard, + ContentCardHeader, + ContentCardTitle, + ContentCardSubtitle, +} from "../../ToolAccordion/AccordionContent"; + +interface Props { + files: FileUIPart[]; + isUser?: boolean; +} + +export function MessageAttachments({ files, isUser }: Props) { + if (files.length === 0) return null; + + return ( +
+ {files.map((file, i) => + isUser ? ( +
+
+
+ +
+

+ {file.filename || "file"} +

+

+ {file.mediaType || "file"} +

+
+
+ {file.url && ( + + + + )} +
+
+ ) : ( + + + + + ) : undefined + } + > +
+ +
+ {file.filename || "file"} + + {file.mediaType || "file"} + +
+
+
+
+ ), + )} +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/EmptySession/EmptySession.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/EmptySession/EmptySession.tsx index cbbe6c570e51..09689305da9c 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/EmptySession/EmptySession.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/EmptySession/EmptySession.tsx @@ -17,13 +17,19 @@ interface Props { inputLayoutId: string; isCreatingSession: boolean; onCreateSession: () => void | Promise; - onSend: (message: string) => void | Promise; + onSend: (message: string, files?: File[]) => void | Promise; + isUploadingFiles?: boolean; + droppedFiles?: File[]; + onDroppedFilesConsumed?: () => void; } export function EmptySession({ inputLayoutId, isCreatingSession, onSend, + isUploadingFiles, + droppedFiles, + onDroppedFilesConsumed, }: Props) { const { user } = useSupabase(); const greetingName = getGreetingName(user); @@ -51,12 +57,12 @@ export function EmptySession({ return (
-
+
Hey, {greetingName} @@ -74,8 +80,11 @@ export function EmptySession({ inputId="chat-input-empty" onSend={onSend} disabled={isCreatingSession} + isUploadingFiles={isUploadingFiles} placeholder={inputPlaceholder} className="w-full" + droppedFiles={droppedFiles} + onDroppedFilesConsumed={onDroppedFilesConsumed} />
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/convertChatSessionToUiMessages.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/convertChatSessionToUiMessages.ts index a9c9f79596e6..ed21bc0c187c 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/convertChatSessionToUiMessages.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/convertChatSessionToUiMessages.ts @@ -1,4 +1,5 @@ -import type { UIMessage, UIDataTypes, UITools } from "ai"; +import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace"; +import type { FileUIPart, UIMessage, UIDataTypes, UITools } from "ai"; interface SessionChatMessage { role: string; @@ -38,6 +39,48 @@ function coerceSessionChatMessages( .filter((m): m is SessionChatMessage => m !== null); } +/** + * Parse the `[Attached files]` block appended by the backend and return + * the cleaned text plus reconstructed FileUIPart objects. + * + * Backend format: + * ``` + * \n\n[Attached files] + * - name.jpg (image/jpeg, 191.0 KB), file_id= + * Use read_workspace_file with the file_id to access file contents. + * ``` + */ +const ATTACHED_FILES_RE = + /\n?\n?\[Attached files\]\n([\s\S]*?)Use read_workspace_file with the file_id to access file contents\./; +const FILE_LINE_RE = /^- (.+) \(([^,]+),\s*[\d.]+ KB\), file_id=([0-9a-f-]+)$/; + +function extractFileParts(content: string): { + cleanText: string; + fileParts: FileUIPart[]; +} { + const match = content.match(ATTACHED_FILES_RE); + if (!match) return { cleanText: content, fileParts: [] }; + + const cleanText = content.replace(match[0], "").trim(); + const lines = match[1].trim().split("\n"); + const fileParts: FileUIPart[] = []; + + for (const line of lines) { + const m = line.trim().match(FILE_LINE_RE); + if (!m) continue; + const [, filename, mimeType, fileId] = m; + const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId); + fileParts.push({ + type: "file", + filename, + mediaType: mimeType, + url: `/api/proxy${apiPath}`, + }); + } + + return { cleanText, fileParts }; +} + function safeJsonParse(value: string): unknown { try { return JSON.parse(value) as unknown; @@ -79,7 +122,17 @@ export function convertChatSessionMessagesToUiMessages( const parts: UIMessage["parts"] = []; if (typeof msg.content === "string" && msg.content.trim()) { - parts.push({ type: "text", text: msg.content, state: "done" }); + if (msg.role === "user") { + const { cleanText, fileParts } = extractFileParts(msg.content); + if (cleanText) { + parts.push({ type: "text", text: cleanText, state: "done" }); + } + for (const fp of fileParts) { + parts.push(fp); + } + } else { + parts.push({ type: "text", text: msg.content, state: "done" }); + } } if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) { diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts index 2deb0912624e..8b0f7bb3e347 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -5,15 +5,25 @@ import { } from "@/app/api/__generated__/endpoints/chat/chat"; import { toast } from "@/components/molecules/Toast/use-toast"; import { useBreakpoint } from "@/lib/hooks/useBreakpoint"; +import { getWebSocketToken } from "@/lib/supabase/actions"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; +import { environment } from "@/services/environment"; import { useQueryClient } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import type { FileUIPart } from "ai"; +import { useEffect, useRef, useState } from "react"; import { useCopilotUIStore } from "./store"; import { useChatSession } from "./useChatSession"; import { useCopilotStream } from "./useCopilotStream"; +interface UploadedFile { + file_id: string; + name: string; + mime_type: string; +} + export function useCopilotPage() { const { isUserLoading, isLoggedIn } = useSupabase(); + const [isUploadingFiles, setIsUploadingFiles] = useState(false); const [pendingMessage, setPendingMessage] = useState(null); const queryClient = useQueryClient(); @@ -77,26 +87,164 @@ export function useCopilotPage() { const isMobile = breakpoint === "base" || breakpoint === "sm" || breakpoint === "md"; + const pendingFilesRef = useRef([]); + // --- Send pending message after session creation --- useEffect(() => { - if (!sessionId || !pendingMessage) return; + if (!sessionId || pendingMessage === null) return; const msg = pendingMessage; + const files = pendingFilesRef.current; setPendingMessage(null); - sendMessage({ text: msg }); + pendingFilesRef.current = []; + + if (files.length > 0) { + setIsUploadingFiles(true); + void uploadFiles(files, sessionId) + .then((uploaded) => { + if (uploaded.length === 0) { + toast({ + title: "File upload failed", + description: "Could not upload any files. Please try again.", + variant: "destructive", + }); + return; + } + const fileParts = buildFileParts(uploaded); + sendMessage({ + text: msg, + files: fileParts.length > 0 ? fileParts : undefined, + }); + }) + .finally(() => setIsUploadingFiles(false)); + } else { + sendMessage({ text: msg }); + } }, [sessionId, pendingMessage, sendMessage]); - async function onSend(message: string) { + async function uploadFiles( + files: File[], + sid: string, + ): Promise { + // Upload directly to the Python backend, bypassing the Next.js serverless + // proxy. Vercel's 4.5 MB function payload limit would reject larger files + // when routed through /api/workspace/files/upload. + const { token, error: tokenError } = await getWebSocketToken(); + if (tokenError || !token) { + toast({ + title: "Authentication error", + description: "Please sign in again.", + variant: "destructive", + }); + return []; + } + + const backendBase = environment.getAGPTServerBaseUrl(); + + const results = await Promise.allSettled( + files.map(async (file) => { + const formData = new FormData(); + formData.append("file", file); + const url = new URL("/api/workspace/files/upload", backendBase); + url.searchParams.set("session_id", sid); + const res = await fetch(url.toString(), { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + if (!res.ok) { + const err = await res.text(); + console.error("File upload failed:", err); + toast({ + title: "File upload failed", + description: file.name, + variant: "destructive", + }); + throw new Error(err); + } + const data = await res.json(); + if (!data.file_id) throw new Error("No file_id returned"); + return { + file_id: data.file_id, + name: data.name || file.name, + mime_type: data.mime_type || "application/octet-stream", + } as UploadedFile; + }), + ); + return results + .filter( + (r): r is PromiseFulfilledResult => + r.status === "fulfilled", + ) + .map((r) => r.value); + } + + function buildFileParts(uploaded: UploadedFile[]): FileUIPart[] { + return uploaded.map((f) => ({ + type: "file" as const, + mediaType: f.mime_type, + filename: f.name, + url: `/api/proxy/api/workspace/files/${f.file_id}/download`, + })); + } + + async function onSend(message: string, files?: File[]) { const trimmed = message.trim(); - if (!trimmed) return; + if (!trimmed && (!files || files.length === 0)) return; + + // Client-side file limits + if (files && files.length > 0) { + const MAX_FILES = 10; + const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB + + if (files.length > MAX_FILES) { + toast({ + title: "Too many files", + description: `You can attach up to ${MAX_FILES} files at once.`, + variant: "destructive", + }); + return; + } + + const oversized = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES); + if (oversized.length > 0) { + toast({ + title: "File too large", + description: `${oversized[0].name} exceeds the 100 MB limit.`, + variant: "destructive", + }); + return; + } + } isUserStoppingRef.current = false; if (sessionId) { - sendMessage({ text: trimmed }); + if (files && files.length > 0) { + setIsUploadingFiles(true); + try { + const uploaded = await uploadFiles(files, sessionId); + if (uploaded.length === 0) { + // All uploads failed — abort send so chips revert to editable + throw new Error("All file uploads failed"); + } + const fileParts = buildFileParts(uploaded); + sendMessage({ + text: trimmed || "", + files: fileParts.length > 0 ? fileParts : undefined, + }); + } finally { + setIsUploadingFiles(false); + } + } else { + sendMessage({ text: trimmed }); + } return; } - setPendingMessage(trimmed); + setPendingMessage(trimmed || ""); + if (files && files.length > 0) { + pendingFilesRef.current = files; + } await createSession(); } @@ -161,6 +309,7 @@ export function useCopilotPage() { isLoadingSession, isSessionError, isCreatingSession, + isUploadingFiles, isUserLoading, isLoggedIn, createSession, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts index 50b64497a069..e47a2b943444 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotStream.ts @@ -8,7 +8,7 @@ import { environment } from "@/services/environment"; import { useChat } from "@ai-sdk/react"; import { useQueryClient } from "@tanstack/react-query"; import { DefaultChatTransport } from "ai"; -import type { UIMessage } from "ai"; +import type { FileUIPart, UIMessage } from "ai"; import { useEffect, useMemo, useRef, useState } from "react"; import { deduplicateMessages, resolveInProgressTools } from "./helpers"; @@ -51,6 +51,15 @@ export function useCopilotStream({ api: `${environment.getAGPTServerBaseUrl()}/api/chat/sessions/${sessionId}/stream`, prepareSendMessagesRequest: async ({ messages }) => { const last = messages[messages.length - 1]; + // Extract file_ids from FileUIPart entries on the message + const fileIds = last.parts + ?.filter((p): p is FileUIPart => p.type === "file") + .map((p) => { + // URL is like /api/proxy/api/workspace/files/{id}/download + const match = p.url.match(/\/workspace\/files\/([^/]+)\//); + return match?.[1]; + }) + .filter(Boolean) as string[] | undefined; return { body: { message: ( @@ -59,6 +68,7 @@ export function useCopilotStream({ ).join(""), is_user_message: last.role === "user", context: null, + file_ids: fileIds && fileIds.length > 0 ? fileIds : null, }, headers: await getAuthHeaders(), }; diff --git a/autogpt_platform/frontend/src/app/api/chat/sessions/[sessionId]/stream/route.ts b/autogpt_platform/frontend/src/app/api/chat/sessions/[sessionId]/stream/route.ts index 55a1442d7274..269c322c823c 100644 --- a/autogpt_platform/frontend/src/app/api/chat/sessions/[sessionId]/stream/route.ts +++ b/autogpt_platform/frontend/src/app/api/chat/sessions/[sessionId]/stream/route.ts @@ -27,9 +27,9 @@ export async function POST( try { const body = await request.json(); - const { message, is_user_message, context } = body; + const { message, is_user_message, context, file_ids } = body; - if (!message) { + if (message === undefined) { return new Response( JSON.stringify({ error: "Missing message parameter" }), { status: 400, headers: { "Content-Type": "application/json" } }, @@ -62,6 +62,7 @@ export async function POST( message, is_user_message: is_user_message ?? true, context: context || null, + file_ids: file_ids || null, }), signal: debugSignal(), }); diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index 6df92d7a6ea6..44ddcac4b171 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -2039,7 +2039,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/UploadFileResponse" } + "schema": { + "$ref": "#/components/schemas/backend__api__model__UploadFileResponse" + } } } }, @@ -6497,6 +6499,59 @@ } } }, + "/api/workspace/files/upload": { + "post": { + "tags": ["workspace"], + "summary": "Upload file to workspace", + "description": "Upload a file to the user's workspace.\n\nFiles are stored in session-scoped paths when session_id is provided,\nso the agent's session-scoped tools can discover them automatically.", + "operationId": "postWorkspaceUpload file to workspace", + "security": [{ "HTTPBearerJWT": [] }], + "parameters": [ + { + "name": "session_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "title": "Session Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_postWorkspaceUpload_file_to_workspace" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backend__api__features__workspace__routes__UploadFileResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/HTTP401NotAuthenticatedError" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, "/api/workspace/files/{file_id}/download": { "get": { "tags": ["workspace"], @@ -6531,6 +6586,30 @@ } } }, + "/api/workspace/storage/usage": { + "get": { + "tags": ["workspace"], + "summary": "Get workspace storage usage", + "description": "Get storage usage information for the user's workspace.", + "operationId": "getWorkspaceGet workspace storage usage", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageUsageResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/HTTP401NotAuthenticatedError" + } + }, + "security": [{ "HTTPBearerJWT": [] }] + } + }, "/health": { "get": { "tags": ["health"], @@ -7768,6 +7847,14 @@ "required": ["file"], "title": "Body_postV2Upload submission media" }, + "Body_postWorkspaceUpload_file_to_workspace": { + "properties": { + "file": { "type": "string", "format": "binary", "title": "File" } + }, + "type": "object", + "required": ["file"], + "title": "Body_postWorkspaceUpload file to workspace" + }, "BulkMoveAgentsRequest": { "properties": { "agent_ids": { @@ -11592,6 +11679,17 @@ "type": "object", "title": "Stats" }, + "StorageUsageResponse": { + "properties": { + "used_bytes": { "type": "integer", "title": "Used Bytes" }, + "limit_bytes": { "type": "integer", "title": "Limit Bytes" }, + "used_percent": { "type": "number", "title": "Used Percent" }, + "file_count": { "type": "integer", "title": "File Count" } + }, + "type": "object", + "required": ["used_bytes", "limit_bytes", "used_percent", "file_count"], + "title": "StorageUsageResponse" + }, "StoreAgent": { "properties": { "slug": { "type": "string", "title": "Slug" }, @@ -12039,6 +12137,17 @@ { "type": "null" } ], "title": "Context" + }, + "file_ids": { + "anyOf": [ + { + "items": { "type": "string" }, + "type": "array", + "maxItems": 20 + }, + { "type": "null" } + ], + "title": "File Ids" } }, "type": "object", @@ -13620,24 +13729,6 @@ "required": ["timezone"], "title": "UpdateTimezoneRequest" }, - "UploadFileResponse": { - "properties": { - "file_uri": { "type": "string", "title": "File Uri" }, - "file_name": { "type": "string", "title": "File Name" }, - "size": { "type": "integer", "title": "Size" }, - "content_type": { "type": "string", "title": "Content Type" }, - "expires_in_hours": { "type": "integer", "title": "Expires In Hours" } - }, - "type": "object", - "required": [ - "file_uri", - "file_name", - "size", - "content_type", - "expires_in_hours" - ], - "title": "UploadFileResponse" - }, "UserHistoryResponse": { "properties": { "history": { @@ -13966,6 +14057,36 @@ "url" ], "title": "Webhook" + }, + "backend__api__features__workspace__routes__UploadFileResponse": { + "properties": { + "file_id": { "type": "string", "title": "File Id" }, + "name": { "type": "string", "title": "Name" }, + "path": { "type": "string", "title": "Path" }, + "mime_type": { "type": "string", "title": "Mime Type" }, + "size_bytes": { "type": "integer", "title": "Size Bytes" } + }, + "type": "object", + "required": ["file_id", "name", "path", "mime_type", "size_bytes"], + "title": "UploadFileResponse" + }, + "backend__api__model__UploadFileResponse": { + "properties": { + "file_uri": { "type": "string", "title": "File Uri" }, + "file_name": { "type": "string", "title": "File Name" }, + "size": { "type": "integer", "title": "Size" }, + "content_type": { "type": "string", "title": "Content Type" }, + "expires_in_hours": { "type": "integer", "title": "Expires In Hours" } + }, + "type": "object", + "required": [ + "file_uri", + "file_name", + "size", + "content_type", + "expires_in_hours" + ], + "title": "UploadFileResponse" } }, "securitySchemes": { diff --git a/autogpt_platform/frontend/src/app/api/workspace/files/upload/route.ts b/autogpt_platform/frontend/src/app/api/workspace/files/upload/route.ts new file mode 100644 index 000000000000..2da5e41aec32 --- /dev/null +++ b/autogpt_platform/frontend/src/app/api/workspace/files/upload/route.ts @@ -0,0 +1,48 @@ +import { environment } from "@/services/environment"; +import { getServerAuthToken } from "@/lib/autogpt-server-api/helpers"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const sessionId = request.nextUrl.searchParams.get("session_id"); + + const token = await getServerAuthToken(); + const backendUrl = environment.getAGPTServerBaseUrl(); + + const uploadUrl = new URL("/api/workspace/files/upload", backendUrl); + if (sessionId) { + uploadUrl.searchParams.set("session_id", sessionId); + } + + const headers: Record = {}; + if (token && token !== "no-token-found") { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch(uploadUrl.toString(), { + method: "POST", + headers, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + return new NextResponse(errorText, { + status: response.status, + }); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error("File upload proxy error:", error); + return NextResponse.json( + { + error: "Failed to upload file", + detail: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +}