feat(platform): Add file upload to copilot chat [SECRT-1788] (#12220)

## Summary

- Add file attachment support to copilot chat (documents, images,
spreadsheets, video, audio)
- Show upload progress with spinner overlays on file chips during upload
- Display attached files as styled pills in sent user messages using AI
SDK's native `FileUIPart`
- Backend upload endpoint with virus scanning (ClamAV), per-file size
limits, and per-user storage caps
- Enrich chat stream with file metadata so the LLM can access files via
`read_workspace_file`

Resolves: [SECRT-1788](https://linear.app/autogpt/issue/SECRT-1788)

### Backend
| File | Change |
|------|--------|
| `chat/routes.py` | Accept `file_ids` in stream request, enrich user
message with file metadata |
| `workspace/routes.py` | New `POST /files/upload` and `GET
/storage/usage` endpoints |
| `executor/utils.py` | Thread `file_ids` through
`CoPilotExecutionEntry` and RabbitMQ |
| `settings.py` | Add `max_file_size_mb` and `max_workspace_storage_mb`
config |

### Frontend
| File | Change |
|------|--------|
| `AttachmentMenu.tsx` | **New** — `+` button with popover for file
category selection |
| `FileChips.tsx` | **New** — file preview chips with upload spinner
state |
| `MessageAttachments.tsx` | **New** — paperclip pills rendering
`FileUIPart` in chat bubbles |
| `upload/route.ts` | **New** — Next.js API proxy for multipart uploads
to backend |
| `ChatInput.tsx` | Integrate attachment menu, file chips, upload
progress |
| `useCopilotPage.ts` | Upload flow, `FileUIPart` construction,
transport `file_ids` extraction |
| `ChatMessagesContainer.tsx` | Render file parts as
`MessageAttachments` |
| `ChatContainer.tsx` / `EmptySession.tsx` | Thread `isUploadingFiles`
prop |
| `useChatInput.ts` | `canSendEmpty` option for file-only sends |
| `stream/route.ts` | Forward `file_ids` to backend |

## Test plan

- [x] Attach files via `+` button → file chips appear with X buttons
- [x] Remove a chip → file is removed from the list
- [x] Send message with files → chips show upload spinners → message
appears with file attachment pills
- [x] Upload failure → toast error, chips revert to editable (no phantom
message sent)
- [x] New session (empty form): same upload flow works
- [x] Messages without files render normally
- [x] Network tab: `file_ids` present in stream POST body

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds authenticated file upload/storage-quota enforcement and threads
`file_ids` through the chat streaming path, which affects data handling
and storage behavior. Risk is mitigated by UUID/workspace scoping, size
limits, and virus scanning but still touches security- and
reliability-sensitive upload flows.
> 
> **Overview**
> Copilot chat now supports attaching files: the frontend adds
drag-and-drop and an attach button, shows selected files as removable
chips with an upload-in-progress state, and renders sent attachments
using AI SDK `FileUIPart` with download links.
> 
> On send, files are uploaded to the backend (with client-side limits
and failure handling) and the chat stream request includes `file_ids`;
the backend sanitizes/filters IDs, scopes them to the user’s workspace,
appends an `[Attached files]` metadata block to the user message for the
LLM, and forwards the sanitized IDs through `enqueue_copilot_turn`.
> 
> The backend adds `POST /workspace/files/upload` (filename
sanitization, per-file size limit, ClamAV scan, and per-user storage
quota with post-write rollback) plus `GET /workspace/storage/usage`,
introduces `max_workspace_storage_mb` config, optimizes workspace size
calculation, and fixes executor cleanup to avoid un-awaited coroutine
warnings; new route tests cover file ID validation and upload quota/scan
behaviors.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
8d3b95d046. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2026-03-03 14:23:27 -06:00
committed by GitHub
parent 9442c648a4
commit 757ec1f064
24 changed files with 1444 additions and 52 deletions

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -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 uuid import uuid4
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 @@ from backend.copilot.tools.models import (
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

View File

@@ -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

View File

@@ -3,15 +3,29 @@ Workspace API routes for managing user file storage.
"""
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,
)

View File

@@ -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

View File

@@ -119,12 +119,12 @@ class CoPilotProcessor:
"""
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}"

View File

@@ -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()

View File

@@ -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)

View File

@@ -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,

View File

@@ -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<File[]>([]);
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 && <ChatSidebar />}
<div className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0">
<div
className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
{/* Drop overlay */}
<div
className={cn(
"pointer-events-none absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-violet-400 bg-violet-500/10 transition-opacity duration-150",
isDragging ? "opacity-100" : "opacity-0",
)}
>
<UploadSimple className="h-10 w-10 text-violet-500" weight="bold" />
<span className="text-lg font-medium text-violet-600">
Drop files here
</span>
</div>
<div className="flex-1 overflow-hidden">
<ChatContainer
messages={messages}
@@ -78,6 +142,9 @@ export function CopilotPage() {
onCreateSession={createSession}
onSend={onSend}
onStop={stop}
isUploadingFiles={isUploadingFiles}
droppedFiles={droppedFiles}
onDroppedFilesConsumed={handleDroppedFilesConsumed}
headerSlot={
isMobile && sessionId ? (
<div className="flex justify-end">

View File

@@ -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<string>;
onSend: (message: string) => void | Promise<void>;
onSend: (message: string, files?: File[]) => void | Promise<void>;
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}
/>
</motion.div>
</div>
@@ -80,6 +91,9 @@ export const ChatContainer = ({
isCreatingSession={isCreatingSession}
onCreateSession={onCreateSession}
onSend={onSend}
isUploadingFiles={isUploadingFiles}
droppedFiles={droppedFiles}
onDroppedFilesConsumed={onDroppedFilesConsumed}
/>
)}
</div>

View File

@@ -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<void>;
onSend: (message: string, files?: File[]) => void | Promise<void>;
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<File[]>([]);
// 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 (
<form onSubmit={handleSubmit} className={cn("relative flex-1", className)}>
@@ -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",
)}
>
<FileChips
files={files}
onRemove={handleRemoveFile}
isUploading={isUploadingFiles}
/>
<PromptInputBody className="relative block w-full">
<PromptInputTextarea
id={inputId}
@@ -104,6 +148,10 @@ export function ChatInput({
<PromptInputFooter>
<PromptInputTools>
<AttachmentMenu
onFilesSelected={handleFilesSelected}
disabled={isBusy}
/>
{showMicButton && (
<PromptInputButton
aria-label={isRecording ? "Stop recording" : "Start recording"}

View File

@@ -0,0 +1,55 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { cn } from "@/lib/utils";
import { Plus as PlusIcon } from "@phosphor-icons/react";
import { useRef } from "react";
interface Props {
onFilesSelected: (files: File[]) => void;
disabled?: boolean;
}
export function AttachmentMenu({ onFilesSelected, disabled }: Props) {
const fileInputRef = useRef<HTMLInputElement>(null);
function handleClick() {
fileInputRef.current?.click();
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
tabIndex={-1}
/>
<Button
type="button"
variant="icon"
size="icon"
aria-label="Attach file"
disabled={disabled}
onClick={handleClick}
className={cn(
"border-zinc-300 bg-white text-zinc-500 hover:border-zinc-400 hover:bg-zinc-50 hover:text-zinc-700",
disabled && "opacity-40",
)}
>
<PlusIcon className="h-4 w-4" weight="bold" />
</Button>
</>
);
}

View File

@@ -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 (
<div className="flex w-full flex-wrap gap-2 px-3 pb-2 pt-1">
{files.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className={cn(
"inline-flex items-center gap-1 rounded-full bg-zinc-100 px-3 py-1 text-sm text-zinc-700",
isUploading && "opacity-70",
)}
>
<span className="max-w-[160px] truncate">{file.name}</span>
{isUploading ? (
<CircleNotchIcon className="ml-0.5 h-3 w-3 animate-spin text-zinc-400" />
) : (
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => onRemove(index)}
className="ml-0.5 rounded-full p-0.5 text-zinc-400 transition-colors hover:bg-zinc-200 hover:text-zinc-600"
>
<XIcon className="h-3 w-3" weight="bold" />
</button>
)}
</span>
))}
</div>
);
}

View File

@@ -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 {

View File

@@ -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 (
<Message from={message.role} key={message.id}>
<MessageContent
@@ -93,6 +98,12 @@ export function ChatMessagesContainer({
<ThinkingIndicator active={showThinking} />
)}
</MessageContent>
{fileParts.length > 0 && (
<MessageAttachments
files={fileParts}
isUser={message.role === "user"}
/>
)}
</Message>
);
})}

View File

@@ -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 (
<div className="mt-2 flex flex-col gap-2">
{files.map((file, i) =>
isUser ? (
<div
key={`${file.filename}-${i}`}
className="min-w-0 rounded-lg border border-purple-300 bg-purple-100 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<FileTextIcon className="h-5 w-5 shrink-0 text-neutral-400" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-zinc-800">
{file.filename || "file"}
</p>
<p className="mt-0.5 truncate font-mono text-xs text-zinc-800">
{file.mediaType || "file"}
</p>
</div>
</div>
{file.url && (
<a
href={file.url}
download
aria-label="Download file"
className="shrink-0 text-purple-400 hover:text-purple-600"
>
<DownloadIcon className="h-5 w-5" />
</a>
)}
</div>
</div>
) : (
<ContentCard key={`${file.filename}-${i}`}>
<ContentCardHeader
action={
file.url ? (
<a
href={file.url}
download
aria-label="Download file"
className="shrink-0 text-neutral-400 hover:text-neutral-600"
>
<DownloadIcon className="h-5 w-5" />
</a>
) : undefined
}
>
<div className="flex items-center gap-2">
<FileTextIcon className="h-5 w-5 shrink-0 text-neutral-400" />
<div className="min-w-0">
<ContentCardTitle>{file.filename || "file"}</ContentCardTitle>
<ContentCardSubtitle>
{file.mediaType || "file"}
</ContentCardSubtitle>
</div>
</div>
</ContentCardHeader>
</ContentCard>
),
)}
</div>
);
}

View File

@@ -17,13 +17,19 @@ interface Props {
inputLayoutId: string;
isCreatingSession: boolean;
onCreateSession: () => void | Promise<string>;
onSend: (message: string) => void | Promise<void>;
onSend: (message: string, files?: File[]) => void | Promise<void>;
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 (
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-0 py-5 md:px-6 md:py-10">
<motion.div
className="w-full max-w-3xl text-center"
className="w-full max-w-[52rem] text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<div className="mx-auto max-w-3xl">
<div className="mx-auto max-w-[52rem]">
<Text variant="h3" className="mb-1 !text-[1.375rem] text-zinc-700">
Hey, <span className="text-violet-600">{greetingName}</span>
</Text>
@@ -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}
/>
</motion.div>
</div>

View File

@@ -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=<uuid>
* 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<unknown, UIDataTypes, UITools>["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)) {

View File

@@ -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<string | null>(null);
const queryClient = useQueryClient();
@@ -77,26 +87,164 @@ export function useCopilotPage() {
const isMobile =
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
const pendingFilesRef = useRef<File[]>([]);
// --- 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<UploadedFile[]> {
// 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<UploadedFile> =>
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,

View File

@@ -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(),
};

View File

@@ -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(),
});

View File

@@ -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": {

View File

@@ -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<string, string> = {};
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 },
);
}
}