From 91e67235df6df7e1ffe4448241e6980841e407a9 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Thu, 26 Feb 2026 18:53:24 -0600 Subject: [PATCH 01/13] feat(platform): Add file upload to copilot chat [SECRT-1788] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable users to attach files (documents, images, spreadsheets, video, audio) to copilot chat messages with upload progress feedback and attachment display in sent messages. Resolves: https://linear.app/autogpt/issue/SECRT-1788 Backend: - Add POST /workspace/files/upload endpoint with virus scanning, size limits, and storage cap enforcement - Add GET /workspace/storage/usage endpoint - Enrich chat stream requests with file metadata so the LLM can reference attached files via read_workspace_file - Thread file_ids through CoPilotExecutionEntry and RabbitMQ queue Frontend: - Add AttachmentMenu (+) popover with file category picker - Add FileChips showing attached files with upload spinner state - Leverage AI SDK native FileUIPart for sent message file parts - Add MessageAttachments component rendering file pills in chat bubbles - Add upload proxy route (Next.js API → backend) - Extract file_ids from FileUIPart URLs in transport layer - Handle upload failures gracefully (chips revert, no phantom messages) Co-Authored-By: Claude Opus 4.6 --- .../backend/api/features/chat/routes.py | 24 +++ .../backend/api/features/workspace/routes.py | 130 ++++++++++++++- .../backend/backend/copilot/executor/utils.py | 6 + .../backend/backend/util/settings.py | 7 + .../app/(platform)/copilot/CopilotPage.tsx | 2 + .../ChatContainer/ChatContainer.tsx | 6 +- .../components/ChatInput/ChatInput.tsx | 137 ++++++++++------ .../ChatInput/components/AttachmentMenu.tsx | 124 ++++++++++++++ .../ChatInput/components/FileChips.tsx | 45 +++++ .../components/ChatInput/useChatInput.ts | 5 +- .../ChatMessagesContainer.tsx | 13 +- .../components/MessageAttachments.tsx | 26 +++ .../components/EmptySession/EmptySession.tsx | 9 +- .../app/(platform)/copilot/useCopilotPage.ts | 120 +++++++++++++- .../chat/sessions/[sessionId]/stream/route.ts | 3 +- .../frontend/src/app/api/openapi.json | 155 +++++++++++++++--- .../app/api/workspace/files/upload/route.ts | 49 ++++++ 17 files changed, 775 insertions(+), 86 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/components/AttachmentMenu.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatInput/components/FileChips.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/MessageAttachments.tsx create mode 100644 autogpt_platform/frontend/src/app/api/workspace/files/upload/route.ts diff --git a/autogpt_platform/backend/backend/api/features/chat/routes.py b/autogpt_platform/backend/backend/api/features/chat/routes.py index dff315f4f655..6fcbedff6b97 100644 --- a/autogpt_platform/backend/backend/api/features/chat/routes.py +++ b/autogpt_platform/backend/backend/api/features/chat/routes.py @@ -79,6 +79,7 @@ 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 = None # Workspace file IDs attached to this message class CreateSessionResponse(BaseModel): @@ -394,6 +395,28 @@ async def stream_chat_post( }, ) + # Enrich message with file metadata if file_ids are provided + if request.file_ids and user_id: + from backend.data.workspace import get_or_create_workspace, get_workspace_file + + workspace = await get_or_create_workspace(user_id) + file_lines: list[str] = [] + for fid in request.file_ids: + wf = await get_workspace_file(fid, workspace.id) + if wf is None: + continue + size_kb = round(wf.size_bytes / 1024, 1) + file_lines.append( + f"- {wf.name} ({wf.mime_type}, {size_kb} KB), file_id={fid}" + ) + 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 +468,7 @@ async def stream_chat_post( turn_id=turn_id, is_user_message=request.is_user_message, context=request.context, + file_ids=request.file_ids, ) setup_time = (time.perf_counter() - stream_start_time) * 1000 diff --git a/autogpt_platform/backend/backend/api/features/workspace/routes.py b/autogpt_platform/backend/backend/api/features/workspace/routes.py index 974465b2c055..bc3ade08714c 100644 --- a/autogpt_platform/backend/backend/api/features/workspace/routes.py +++ b/autogpt_platform/backend/backend/api/features/workspace/routes.py @@ -9,9 +9,21 @@ import fastapi from autogpt_libs.auth.dependencies import get_user_id, requires_user +from fastapi import Query, UploadFile from fastapi.responses import Response - -from backend.data.workspace import WorkspaceFile, get_workspace, get_workspace_file +from pydantic import BaseModel + +from backend.data.workspace import ( + WorkspaceFile, + count_workspace_files, + get_or_create_workspace, + get_workspace, + get_workspace_file, + get_workspace_total_size, +) +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 +110,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 +147,102 @@ 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() + + # 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=400, + 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) + + # Check storage cap + storage_limit_bytes = config.max_workspace_storage_mb * 1024 * 1024 + current_usage = await get_workspace_total_size(workspace.id) + if 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 + usage_ratio = (current_usage + len(content)) / storage_limit_bytes + if usage_ratio >= 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 + filename = file.filename or "upload" + 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) + + 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/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/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index 91b3b0f8ca2f..987f41a84659 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..bdae88bda4cb 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx @@ -29,6 +29,7 @@ export function CopilotPage() { isLoadingSession, isSessionError, isCreatingSession, + isUploadingFiles, isUserLoading, isLoggedIn, // Mobile drawer @@ -78,6 +79,7 @@ export function CopilotPage() { onCreateSession={createSession} onSend={onSend} onStop={stop} + isUploadingFiles={isUploadingFiles} headerSlot={ isMobile && sessionId ? (
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..db3f9b345d56 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,8 +18,9 @@ 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; } export const ChatContainer = ({ @@ -34,6 +35,7 @@ export const ChatContainer = ({ onCreateSession, onSend, onStop, + isUploadingFiles, headerSlot, }: ChatContainerProps) => { const isBusy = @@ -69,6 +71,7 @@ export const ChatContainer = ({ onSend={onSend} disabled={isBusy} isStreaming={isBusy} + isUploadingFiles={isUploadingFiles} onStop={onStop} placeholder="What else can I help with?" /> @@ -80,6 +83,7 @@ export const ChatContainer = ({ isCreatingSession={isCreatingSession} onCreateSession={onCreateSession} onSend={onSend} + isUploadingFiles={isUploadingFiles} /> )}
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 af44232a4aff..61d94ac2ff5e 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 @@ -6,15 +6,18 @@ import { MicrophoneIcon, StopIcon, } from "@phosphor-icons/react"; -import { ChangeEvent, useCallback } from "react"; +import { ChangeEvent, useCallback, 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; @@ -25,11 +28,17 @@ export function ChatInput({ onSend, disabled = false, isStreaming = false, + isUploadingFiles = false, onStop, placeholder = "Type your message...", className, inputId = "chat-input", }: Props) { + const [files, setFiles] = useState([]); + + const hasFiles = files.length > 0; + const isBusy = disabled || isStreaming || isUploadingFiles; + const { value, setValue, @@ -38,8 +47,13 @@ export function ChatInput({ handleChange: baseHandleChange, hasMultipleLines, } = 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, maxRows: 4, inputId, }); @@ -55,7 +69,7 @@ export function ChatInput({ audioStream, } = useVoiceRecording({ setValue, - disabled: disabled || isStreaming, + disabled: isBusy, isStreaming, value, baseHandleKeyDown, @@ -71,70 +85,91 @@ export function ChatInput({ [isRecording, baseHandleChange], ); + function handleFilesSelected(newFiles: File[]) { + setFiles((prev) => [...prev, ...newFiles]); + } + + function handleRemoveFile(index: number) { + setFiles((prev) => prev.filter((_, i) => i !== index)); + } + + const isExpanded = hasMultipleLines || hasFiles; + return (
- {!value && !isRecording && ( - - )} -