diff --git a/autogpt_platform/backend/backend/api/features/library/db.py b/autogpt_platform/backend/backend/api/features/library/db.py index e07ed9f7ad..4e9ac5bd2b 100644 --- a/autogpt_platform/backend/backend/api/features/library/db.py +++ b/autogpt_platform/backend/backend/api/features/library/db.py @@ -7,6 +7,7 @@ import prisma.errors import prisma.models import prisma.types +from backend.api.features.library.exceptions import FolderValidationError import backend.api.features.store.exceptions as store_exceptions import backend.api.features.store.image_gen as store_image_gen import backend.api.features.store.media as store_media @@ -42,6 +43,8 @@ async def list_library_agents( page: int = 1, page_size: int = 50, include_executions: bool = False, + folder_id: Optional[str] = None, + include_root_only: bool = False, ) -> library_model.LibraryAgentResponse: """ Retrieves a paginated list of LibraryAgent records for a given user. @@ -52,6 +55,8 @@ async def list_library_agents( sort_by: Sorting field (createdAt, updatedAt, isFavorite, isCreatedByUser). page: Current page (1-indexed). page_size: Number of items per page. + folder_id: Filter by folder ID. If provided, only returns agents in this folder. + include_root_only: If True, only returns agents without a folder (root-level). include_executions: Whether to include execution data for status calculation. Defaults to False for performance (UI fetches status separately). Set to True when accurate status/metrics are needed (e.g., agent generator). @@ -82,6 +87,13 @@ async def list_library_agents( "isArchived": False, } + # Apply folder filter + if folder_id is not None: + where_clause["folderId"] = folder_id + elif include_root_only: + where_clause["folderId"] = None + + # Build search filter if applicable if search_term: where_clause["OR"] = [ { @@ -634,6 +646,7 @@ async def update_library_agent( is_archived: Optional[bool] = None, is_deleted: Optional[Literal[False]] = None, settings: Optional[GraphSettings] = None, + folder_id: Optional[str] = None, ) -> library_model.LibraryAgent: """ Updates the specified LibraryAgent record. @@ -646,6 +659,7 @@ async def update_library_agent( is_favorite: Whether this agent is marked as a favorite. is_archived: Whether this agent is archived. settings: User-specific settings for this library agent. + folder_id: Folder ID to move agent to (empty string "" for root, None to skip). Returns: The updated LibraryAgent. @@ -673,13 +687,7 @@ async def update_library_agent( ) update_fields["isDeleted"] = is_deleted if settings is not None: - existing_agent = await get_library_agent(id=library_agent_id, user_id=user_id) - current_settings_dict = ( - existing_agent.settings.model_dump() if existing_agent.settings else {} - ) - new_settings = settings.model_dump(exclude_unset=True) - merged_settings = {**current_settings_dict, **new_settings} - update_fields["settings"] = SafeJson(merged_settings) + update_fields["settings"] = SafeJson(settings.model_dump()) try: # If graph_version is provided, update to that specific version @@ -918,6 +926,788 @@ async def add_store_agent_to_library( raise DatabaseError("Failed to add agent to library") from e +############################################## +############ Folder DB Functions ############# +############################################## + +MAX_FOLDER_DEPTH = 5 + + +async def list_folders( + user_id: str, + parent_id: Optional[str] = None, + include_counts: bool = True, +) -> list[library_model.LibraryFolder]: + """ + Lists folders for a user, optionally filtered by parent. + + Args: + user_id: The ID of the user. + parent_id: If provided, only returns folders with this parent. + If None, returns root-level folders. + include_counts: Whether to include agent and subfolder counts. + + Returns: + A list of LibraryFolder objects. + """ + logger.debug(f"Listing folders for user #{user_id}, parent_id={parent_id}") + + try: + where_clause: prisma.types.LibraryFolderWhereInput = { + "userId": user_id, + "isDeleted": False, + "parentId": parent_id, + } + + folders = await prisma.models.LibraryFolder.prisma().find_many( + where=where_clause, + order={"createdAt": "asc"}, + include=( + { + "LibraryAgents": {"where": {"isDeleted": False}}, + "Children": {"where": {"isDeleted": False}}, + } + if include_counts + else None + ), + ) + + result = [] + for folder in folders: + agent_count = len(folder.LibraryAgents) if folder.LibraryAgents else 0 + subfolder_count = len(folder.Children) if folder.Children else 0 + result.append( + library_model.LibraryFolder.from_db( + folder, + agent_count=agent_count, + subfolder_count=subfolder_count, + ) + ) + + return result + + except prisma.errors.PrismaError as e: + logger.error(f"Database error listing folders: {e}") + raise DatabaseError("Failed to list folders") from e + + +async def get_folder_tree( + user_id: str, +) -> list[library_model.LibraryFolderTree]: + """ + Gets the full folder tree for a user. + + Args: + user_id: The ID of the user. + + Returns: + A list of LibraryFolderTree objects (root folders with nested children). + """ + logger.debug(f"Getting folder tree for user #{user_id}") + + try: + # Fetch all folders for the user + all_folders = await prisma.models.LibraryFolder.prisma().find_many( + where={ + "userId": user_id, + "isDeleted": False, + }, + order={"createdAt": "asc"}, + include={ + "LibraryAgents": {"where": {"isDeleted": False}}, + "Children": {"where": {"isDeleted": False}}, + }, + ) + + # Build a map of folder ID to folder data + folder_map: dict[str, library_model.LibraryFolderTree] = {} + for folder in all_folders: + agent_count = len(folder.LibraryAgents) if folder.LibraryAgents else 0 + subfolder_count = len(folder.Children) if folder.Children else 0 + folder_map[folder.id] = library_model.LibraryFolderTree( + **library_model.LibraryFolder.from_db( + folder, + agent_count=agent_count, + subfolder_count=subfolder_count, + ).model_dump(), + children=[], + ) + + # Build the tree structure + root_folders: list[library_model.LibraryFolderTree] = [] + for folder in all_folders: + tree_folder = folder_map[folder.id] + if folder.parentId and folder.parentId in folder_map: + folder_map[folder.parentId].children.append(tree_folder) + else: + root_folders.append(tree_folder) + + return root_folders + + except prisma.errors.PrismaError as e: + logger.error(f"Database error getting folder tree: {e}") + raise DatabaseError("Failed to get folder tree") from e + + +async def get_folder( + folder_id: str, + user_id: str, +) -> library_model.LibraryFolder: + """ + Gets a single folder by ID. + + Args: + folder_id: The ID of the folder. + user_id: The ID of the user (for ownership verification). + + Returns: + The LibraryFolder object. + + Raises: + NotFoundError: If the folder doesn't exist or doesn't belong to the user. + """ + try: + folder = await prisma.models.LibraryFolder.prisma().find_first( + where={ + "id": folder_id, + "userId": user_id, + "isDeleted": False, + }, + include={ + "LibraryAgents": {"where": {"isDeleted": False}}, + "Children": {"where": {"isDeleted": False}}, + }, + ) + + if not folder: + raise NotFoundError(f"Folder #{folder_id} not found") + + agent_count = len(folder.LibraryAgents) if folder.LibraryAgents else 0 + subfolder_count = len(folder.Children) if folder.Children else 0 + + return library_model.LibraryFolder.from_db( + folder, + agent_count=agent_count, + subfolder_count=subfolder_count, + ) + + except prisma.errors.PrismaError as e: + logger.error(f"Database error getting folder: {e}") + raise DatabaseError("Failed to get folder") from e + + +async def get_folder_depth(folder_id: str, user_id: str) -> int: + """ + Calculate the depth of a folder in the hierarchy (root=0). + + Args: + folder_id: The ID of the folder. + user_id: The ID of the user. + + Returns: + The depth of the folder (0 for root-level folders). + """ + depth = 0 + current_id: str | None = folder_id + + while current_id: + folder = await prisma.models.LibraryFolder.prisma().find_first( + where={ + "id": current_id, + "userId": user_id, + "isDeleted": False, + } + ) + if not folder: + break + if folder.parentId: + depth += 1 + current_id = folder.parentId + else: + break + + return depth + + +async def is_descendant_of( + folder_id: str, + potential_ancestor_id: str, + user_id: str, +) -> bool: + """ + Check if folder_id is a descendant of potential_ancestor_id. + + Args: + folder_id: The ID of the folder to check. + potential_ancestor_id: The ID of the potential ancestor. + user_id: The ID of the user. + + Returns: + True if folder_id is a descendant of potential_ancestor_id. + """ + current_id: str | None = folder_id + + while current_id: + if current_id == potential_ancestor_id: + return True + + folder = await prisma.models.LibraryFolder.prisma().find_first( + where={ + "id": current_id, + "userId": user_id, + "isDeleted": False, + } + ) + if not folder or not folder.parentId: + break + current_id = folder.parentId + + return False + + +async def validate_folder_operation( + folder_id: Optional[str], + target_parent_id: Optional[str], + user_id: str, + max_depth: int = MAX_FOLDER_DEPTH, +) -> None: + """ + Validate that a folder move/create operation is valid. + + Args: + folder_id: The ID of the folder being moved (None for create). + target_parent_id: The target parent ID (None for root). + user_id: The ID of the user. + max_depth: Maximum allowed nesting depth. + + Raises: + FolderValidationError: If the operation is invalid. + """ + # Cannot move folder into itself + if folder_id and folder_id == target_parent_id: + raise FolderValidationError("Cannot move folder into itself") + + # Check for circular reference + if folder_id and target_parent_id: + if await is_descendant_of(target_parent_id, folder_id, user_id): + raise FolderValidationError("Cannot move folder into its own descendant") + + # Check depth limit + if target_parent_id: + parent_depth = await get_folder_depth(target_parent_id, user_id) + if parent_depth + 1 >= max_depth: + raise FolderValidationError( + f"Maximum folder nesting depth of {max_depth} exceeded" + ) + + +async def create_folder( + user_id: str, + name: str, + parent_id: Optional[str] = None, + icon: Optional[str] = None, + color: Optional[str] = None, +) -> library_model.LibraryFolder: + """ + Creates a new folder for the user. + + Args: + user_id: The ID of the user. + name: The folder name. + parent_id: Optional parent folder ID. + icon: Optional icon identifier. + color: Optional hex color code. + + Returns: + The created LibraryFolder. + + Raises: + FolderValidationError: If validation fails. + DatabaseError: If there's a database error. + """ + logger.debug(f"Creating folder '{name}' for user #{user_id}") + + try: + # Validate operation + await validate_folder_operation( + folder_id=None, + target_parent_id=parent_id, + user_id=user_id, + ) + + # Verify parent exists if provided + if parent_id: + parent = await prisma.models.LibraryFolder.prisma().find_first( + where={ + "id": parent_id, + "userId": user_id, + "isDeleted": False, + } + ) + if not parent: + raise NotFoundError(f"Parent folder #{parent_id} not found") + + # Build data dict conditionally - don't include Parent key if no parent_id + create_data: dict = { + "name": name, + "User": {"connect": {"id": user_id}}, + } + if icon is not None: + create_data["icon"] = icon + if color is not None: + create_data["color"] = color + if parent_id: + create_data["Parent"] = {"connect": {"id": parent_id}} + + folder = await prisma.models.LibraryFolder.prisma().create(data=create_data) + + return library_model.LibraryFolder.from_db(folder) + + except prisma.errors.UniqueViolationError: + raise FolderValidationError( + "A folder with this name already exists in this location" + ) + except prisma.errors.PrismaError as e: + logger.error(f"Database error creating folder: {e}") + raise DatabaseError("Failed to create folder") from e + + +async def create_folder_with_unique_name( + user_id: str, + base_name: str, + parent_id: Optional[str] = None, + icon: Optional[str] = None, + color: Optional[str] = None, +) -> library_model.LibraryFolder: + """ + Creates a folder, appending (2), (3), etc. if name exists. + + Args: + user_id: The ID of the user. + base_name: The base folder name. + parent_id: Optional parent folder ID. + icon: Optional icon identifier. + color: Optional hex color code. + + Returns: + The created LibraryFolder. + """ + name = base_name + suffix = 1 + + while True: + try: + return await create_folder( + user_id=user_id, + name=name, + parent_id=parent_id, + icon=icon, + color=color, + ) + except FolderValidationError as e: + if "already exists" in str(e): + suffix += 1 + name = f"{base_name} ({suffix})" + else: + raise + + +async def update_folder( + folder_id: str, + user_id: str, + name: Optional[str] = None, + icon: Optional[str] = None, + color: Optional[str] = None, +) -> library_model.LibraryFolder: + """ + Updates a folder's properties. + + Args: + folder_id: The ID of the folder to update. + user_id: The ID of the user. + name: New folder name. + icon: New icon identifier. + color: New hex color code. + + Returns: + The updated LibraryFolder. + + Raises: + NotFoundError: If the folder doesn't exist. + DatabaseError: If there's a database error. + """ + logger.debug(f"Updating folder #{folder_id} for user #{user_id}") + + try: + # Verify folder exists and belongs to user + existing = await prisma.models.LibraryFolder.prisma().find_first( + where={ + "id": folder_id, + "userId": user_id, + "isDeleted": False, + } + ) + if not existing: + raise NotFoundError(f"Folder #{folder_id} not found") + + update_data: prisma.types.LibraryFolderUpdateInput = {} + if name is not None: + update_data["name"] = name + if icon is not None: + update_data["icon"] = icon + if color is not None: + update_data["color"] = color + + if not update_data: + return await get_folder(folder_id, user_id) + + folder = await prisma.models.LibraryFolder.prisma().update( + where={"id": folder_id}, + data=update_data, + include={ + "LibraryAgents": {"where": {"isDeleted": False}}, + "Children": {"where": {"isDeleted": False}}, + }, + ) + + if not folder: + raise NotFoundError(f"Folder #{folder_id} not found") + + agent_count = len(folder.LibraryAgents) if folder.LibraryAgents else 0 + subfolder_count = len(folder.Children) if folder.Children else 0 + + return library_model.LibraryFolder.from_db( + folder, + agent_count=agent_count, + subfolder_count=subfolder_count, + ) + + except prisma.errors.UniqueViolationError: + raise FolderValidationError( + "A folder with this name already exists in this location" + ) + except prisma.errors.PrismaError as e: + logger.error(f"Database error updating folder: {e}") + raise DatabaseError("Failed to update folder") from e + + +async def move_folder( + folder_id: str, + user_id: str, + target_parent_id: Optional[str], +) -> library_model.LibraryFolder: + """ + Moves a folder to a new parent. + + Args: + folder_id: The ID of the folder to move. + user_id: The ID of the user. + target_parent_id: The target parent ID (None for root). + + Returns: + The moved LibraryFolder. + + Raises: + FolderValidationError: If the move is invalid. + NotFoundError: If the folder doesn't exist. + DatabaseError: If there's a database error. + """ + logger.debug(f"Moving folder #{folder_id} to parent #{target_parent_id}") + + try: + # Validate operation + await validate_folder_operation( + folder_id=folder_id, + target_parent_id=target_parent_id, + user_id=user_id, + ) + + # Verify folder exists + existing = await prisma.models.LibraryFolder.prisma().find_first( + where={ + "id": folder_id, + "userId": user_id, + "isDeleted": False, + } + ) + if not existing: + raise NotFoundError(f"Folder #{folder_id} not found") + + # Verify target parent exists if provided + if target_parent_id: + parent = await prisma.models.LibraryFolder.prisma().find_first( + where={ + "id": target_parent_id, + "userId": user_id, + "isDeleted": False, + } + ) + if not parent: + raise NotFoundError( + f"Target parent folder #{target_parent_id} not found" + ) + + folder = await prisma.models.LibraryFolder.prisma().update( + where={"id": folder_id}, + data={ + "parentId": target_parent_id, + }, + include={ + "LibraryAgents": {"where": {"isDeleted": False}}, + "Children": {"where": {"isDeleted": False}}, + }, + ) + + if not folder: + raise NotFoundError(f"Folder #{folder_id} not found") + + agent_count = len(folder.LibraryAgents) if folder.LibraryAgents else 0 + subfolder_count = len(folder.Children) if folder.Children else 0 + + return library_model.LibraryFolder.from_db( + folder, + agent_count=agent_count, + subfolder_count=subfolder_count, + ) + + except prisma.errors.UniqueViolationError: + raise FolderValidationError( + "A folder with this name already exists in this location" + ) + except prisma.errors.PrismaError as e: + logger.error(f"Database error moving folder: {e}") + raise DatabaseError("Failed to move folder") from e + + +async def delete_folder( + folder_id: str, + user_id: str, + soft_delete: bool = True, +) -> None: + """ + Deletes a folder and all its contents (cascade). + + Args: + folder_id: The ID of the folder to delete. + user_id: The ID of the user. + soft_delete: If True, soft-deletes; otherwise hard-deletes. + + Raises: + NotFoundError: If the folder doesn't exist. + DatabaseError: If there's a database error. + """ + logger.debug(f"Deleting folder #{folder_id} for user #{user_id}") + + try: + # Verify folder exists + existing = await prisma.models.LibraryFolder.prisma().find_first( + where={ + "id": folder_id, + "userId": user_id, + "isDeleted": False, + } + ) + if not existing: + raise NotFoundError(f"Folder #{folder_id} not found") + + async with transaction() as tx: + # Get all descendant folders recursively + descendant_ids = await _get_descendant_folder_ids(folder_id, user_id, tx) + all_folder_ids = [folder_id] + descendant_ids + + if soft_delete: + # Soft-delete all agents in these folders + await prisma.models.LibraryAgent.prisma(tx).update_many( + where={ + "folderId": {"in": all_folder_ids}, + "userId": user_id, + }, + data={"isDeleted": True}, + ) + + # Soft-delete all folders + await prisma.models.LibraryFolder.prisma(tx).update_many( + where={ + "id": {"in": all_folder_ids}, + "userId": user_id, + }, + data={"isDeleted": True}, + ) + else: + # Move agents to root (or could hard-delete them) + await prisma.models.LibraryAgent.prisma(tx).update_many( + where={ + "folderId": {"in": all_folder_ids}, + "userId": user_id, + }, + data={"folderId": None}, + ) + + # Hard-delete folders (children first due to FK constraints) + for fid in reversed(all_folder_ids): + await prisma.models.LibraryFolder.prisma(tx).delete( + where={"id": fid} + ) + + except prisma.errors.PrismaError as e: + logger.error(f"Database error deleting folder: {e}") + raise DatabaseError("Failed to delete folder") from e + + +async def _get_descendant_folder_ids( + folder_id: str, + user_id: str, + tx: Optional[prisma.Prisma] = None, +) -> list[str]: + """ + Recursively get all descendant folder IDs. + + Args: + folder_id: The ID of the parent folder. + user_id: The ID of the user. + tx: Optional transaction. + + Returns: + A list of descendant folder IDs. + """ + prisma_client = prisma.models.LibraryFolder.prisma(tx) + children = await prisma_client.find_many( + where={ + "parentId": folder_id, + "userId": user_id, + "isDeleted": False, + } + ) + + result: list[str] = [] + for child in children: + result.append(child.id) + result.extend(await _get_descendant_folder_ids(child.id, user_id, tx)) + + return result + + +async def move_agent_to_folder( + library_agent_id: str, + folder_id: Optional[str], + user_id: str, +) -> library_model.LibraryAgent: + """ + Moves a library agent to a folder. + + Args: + library_agent_id: The ID of the library agent. + folder_id: The target folder ID (None for root). + user_id: The ID of the user. + + Returns: + The updated LibraryAgent. + + Raises: + NotFoundError: If the agent or folder doesn't exist. + DatabaseError: If there's a database error. + """ + logger.debug(f"Moving agent #{library_agent_id} to folder #{folder_id}") + + try: + # Verify agent exists + agent = await prisma.models.LibraryAgent.prisma().find_first( + where={ + "id": library_agent_id, + "userId": user_id, + "isDeleted": False, + } + ) + if not agent: + raise NotFoundError(f"Library agent #{library_agent_id} not found") + + # Verify folder exists if provided + if folder_id: + folder = await prisma.models.LibraryFolder.prisma().find_first( + where={ + "id": folder_id, + "userId": user_id, + "isDeleted": False, + } + ) + if not folder: + raise NotFoundError(f"Folder #{folder_id} not found") + + await prisma.models.LibraryAgent.prisma().update( + where={"id": library_agent_id}, + data={"folderId": folder_id}, + ) + + return await get_library_agent(library_agent_id, user_id) + + except prisma.errors.PrismaError as e: + logger.error(f"Database error moving agent to folder: {e}") + raise DatabaseError("Failed to move agent to folder") from e + + +async def bulk_move_agents_to_folder( + agent_ids: list[str], + folder_id: Optional[str], + user_id: str, +) -> list[library_model.LibraryAgent]: + """ + Moves multiple library agents to a folder. + + Args: + agent_ids: The IDs of the library agents. + folder_id: The target folder ID (None for root). + user_id: The ID of the user. + + Returns: + The updated LibraryAgents. + + Raises: + NotFoundError: If any agent or the folder doesn't exist. + DatabaseError: If there's a database error. + """ + logger.debug(f"Bulk moving {len(agent_ids)} agents to folder #{folder_id}") + + try: + # Verify folder exists if provided + if folder_id: + folder = await prisma.models.LibraryFolder.prisma().find_first( + where={ + "id": folder_id, + "userId": user_id, + "isDeleted": False, + } + ) + if not folder: + raise NotFoundError(f"Folder #{folder_id} not found") + + # Update all agents + await prisma.models.LibraryAgent.prisma().update_many( + where={ + "id": {"in": agent_ids}, + "userId": user_id, + "isDeleted": False, + }, + data={"folderId": folder_id}, + ) + + # Fetch and return updated agents + agents = await prisma.models.LibraryAgent.prisma().find_many( + where={ + "id": {"in": agent_ids}, + "userId": user_id, + }, + include=library_agent_include( + user_id, include_nodes=False, include_executions=False + ), + ) + + return [library_model.LibraryAgent.from_db(agent) for agent in agents] + + except prisma.errors.PrismaError as e: + logger.error(f"Database error bulk moving agents to folder: {e}") + raise DatabaseError("Failed to bulk move agents to folder") from e + + ############################################## ########### Presets DB Functions ############# ############################################## diff --git a/autogpt_platform/backend/backend/api/features/library/exceptions.py b/autogpt_platform/backend/backend/api/features/library/exceptions.py new file mode 100644 index 0000000000..1288f0807c --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/library/exceptions.py @@ -0,0 +1,4 @@ +class FolderValidationError(Exception): + """Raised when folder operations fail validation.""" + + pass \ No newline at end of file diff --git a/autogpt_platform/backend/backend/api/features/library/model.py b/autogpt_platform/backend/backend/api/features/library/model.py index 9ecbaecccb..40ac9c59b1 100644 --- a/autogpt_platform/backend/backend/api/features/library/model.py +++ b/autogpt_platform/backend/backend/api/features/library/model.py @@ -26,6 +26,95 @@ class LibraryAgentStatus(str, Enum): ERROR = "ERROR" +# === Folder Models === + + +class LibraryFolder(pydantic.BaseModel): + """Represents a folder for organizing library agents.""" + + id: str + user_id: str + name: str + icon: str | None = None + color: str | None = None + parent_id: str | None = None + created_at: datetime.datetime + updated_at: datetime.datetime + agent_count: int = 0 # Direct agents in folder + subfolder_count: int = 0 # Direct child folders + + @staticmethod + def from_db( + folder: prisma.models.LibraryFolder, + agent_count: int = 0, + subfolder_count: int = 0, + ) -> "LibraryFolder": + """Factory method that constructs a LibraryFolder from a Prisma model.""" + return LibraryFolder( + id=folder.id, + user_id=folder.userId, + name=folder.name, + icon=folder.icon, + color=folder.color, + parent_id=folder.parentId, + created_at=folder.createdAt, + updated_at=folder.updatedAt, + agent_count=agent_count, + subfolder_count=subfolder_count, + ) + + +class LibraryFolderTree(LibraryFolder): + """Folder with nested children for tree view.""" + + children: list["LibraryFolderTree"] = [] + + +class FolderCreateRequest(pydantic.BaseModel): + """Request model for creating a folder.""" + + name: str = pydantic.Field(..., min_length=1, max_length=100) + icon: str | None = None + color: str | None = pydantic.Field( + None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code (#RRGGBB)" + ) + parent_id: str | None = None + + +class FolderUpdateRequest(pydantic.BaseModel): + """Request model for updating a folder.""" + + name: str | None = pydantic.Field(None, min_length=1, max_length=100) + icon: str | None = None + color: str | None = None + + +class FolderMoveRequest(pydantic.BaseModel): + """Request model for moving a folder to a new parent.""" + + target_parent_id: str | None = None # None = move to root + + +class BulkMoveAgentsRequest(pydantic.BaseModel): + """Request model for moving multiple agents to a folder.""" + + agent_ids: list[str] + folder_id: str | None = None # None = move to root + + +class FolderListResponse(pydantic.BaseModel): + """Response schema for a list of folders.""" + + folders: list[LibraryFolder] + pagination: Pagination + + +class FolderTreeResponse(pydantic.BaseModel): + """Response schema for folder tree structure.""" + + tree: list[LibraryFolderTree] + + class MarketplaceListingCreator(pydantic.BaseModel): """Creator information for a marketplace listing.""" @@ -120,6 +209,9 @@ class LibraryAgent(pydantic.BaseModel): can_access_graph: bool is_latest_version: bool is_favorite: bool + folder_id: str | None = None + folder_name: str | None = None # Denormalized for display + recommended_schedule_cron: str | None = None settings: GraphSettings = pydantic.Field(default_factory=GraphSettings) marketplace_listing: Optional["MarketplaceListing"] = None @@ -228,6 +320,10 @@ class LibraryAgent(pydantic.BaseModel): creator=creator_data, ) + # Folder information + folder_id = agent.folderId + folder_name = agent.Folder.name if agent.Folder else None + return LibraryAgent( id=agent.id, graph_id=agent.agentGraphId, @@ -259,6 +355,8 @@ class LibraryAgent(pydantic.BaseModel): can_access_graph=can_access_graph, is_latest_version=is_latest_version, is_favorite=agent.isFavorite, + folder_id=folder_id, + folder_name=folder_name, recommended_schedule_cron=agent.AgentGraph.recommendedScheduleCron, settings=_parse_settings(agent.settings), marketplace_listing=marketplace_listing_data, @@ -470,3 +568,7 @@ class LibraryAgentUpdateRequest(pydantic.BaseModel): settings: Optional[GraphSettings] = pydantic.Field( default=None, description="User-specific settings for this library agent" ) + folder_id: Optional[str] = pydantic.Field( + default=None, + description="Folder ID to move agent to (empty string for root)", + ) diff --git a/autogpt_platform/backend/backend/api/features/library/routes/__init__.py b/autogpt_platform/backend/backend/api/features/library/routes/__init__.py index f62cbe7ff2..c414e35911 100644 --- a/autogpt_platform/backend/backend/api/features/library/routes/__init__.py +++ b/autogpt_platform/backend/backend/api/features/library/routes/__init__.py @@ -1,9 +1,11 @@ import fastapi from .agents import router as agents_router +from .folders import router as folders_router from .presets import router as presets_router router = fastapi.APIRouter() router.include_router(presets_router) +router.include_router(folders_router) router.include_router(agents_router) diff --git a/autogpt_platform/backend/backend/api/features/library/routes/agents.py b/autogpt_platform/backend/backend/api/features/library/routes/agents.py index fa3d1a0f0c..9a3f687abc 100644 --- a/autogpt_platform/backend/backend/api/features/library/routes/agents.py +++ b/autogpt_platform/backend/backend/api/features/library/routes/agents.py @@ -41,17 +41,34 @@ async def list_library_agents( ge=1, description="Number of agents per page (must be >= 1)", ), + folder_id: Optional[str] = Query( + None, + description="Filter by folder ID", + ), + include_root_only: bool = Query( + False, + description="Only return agents without a folder (root-level agents)", + ), ) -> library_model.LibraryAgentResponse: """ Get all agents in the user's library (both created and saved). """ - return await library_db.list_library_agents( - user_id=user_id, - search_term=search_term, - sort_by=sort_by, - page=page, - page_size=page_size, - ) + try: + return await library_db.list_library_agents( + user_id=user_id, + search_term=search_term, + sort_by=sort_by, + page=page, + page_size=page_size, + folder_id=folder_id, + include_root_only=include_root_only, + ) + except Exception as e: + logger.error(f"Could not list library agents for user #{user_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e @router.get( @@ -160,15 +177,34 @@ async def update_library_agent( """ Update the library agent with the given fields. """ - return await library_db.update_library_agent( - library_agent_id=library_agent_id, - user_id=user_id, - auto_update_version=payload.auto_update_version, - graph_version=payload.graph_version, - is_favorite=payload.is_favorite, - is_archived=payload.is_archived, - settings=payload.settings, - ) + try: + return await library_db.update_library_agent( + library_agent_id=library_agent_id, + user_id=user_id, + auto_update_version=payload.auto_update_version, + graph_version=payload.graph_version, + is_favorite=payload.is_favorite, + is_archived=payload.is_archived, + settings=payload.settings, + folder_id=payload.folder_id, + ) + except NotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except DatabaseError as e: + logger.error(f"Database error while updating library agent: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"message": str(e), "hint": "Verify DB connection."}, + ) from e + except Exception as e: + logger.error(f"Unexpected error while updating library agent: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"message": str(e), "hint": "Check server logs."}, + ) from e @router.delete( diff --git a/autogpt_platform/backend/backend/api/features/library/routes/folders.py b/autogpt_platform/backend/backend/api/features/library/routes/folders.py new file mode 100644 index 0000000000..324fbd5c08 --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/library/routes/folders.py @@ -0,0 +1,408 @@ +import logging +from typing import Optional + +import autogpt_libs.auth as autogpt_auth_lib +from fastapi import APIRouter, HTTPException, Query, Security, status +from fastapi.responses import Response + +from backend.util.exceptions import DatabaseError, NotFoundError + +from .. import db as library_db +from .. import model as library_model + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/folders", + tags=["library", "folders", "private"], + dependencies=[Security(autogpt_auth_lib.requires_user)], +) + + +@router.get( + "", + summary="List Library Folders", + response_model=library_model.FolderListResponse, + responses={ + 200: {"description": "List of folders"}, + 500: {"description": "Server error"}, + }, +) +async def list_folders( + user_id: str = Security(autogpt_auth_lib.get_user_id), + parent_id: Optional[str] = Query( + None, + description="Filter by parent folder ID. If not provided, returns root-level folders.", + ), + include_counts: bool = Query( + True, + description="Include agent and subfolder counts", + ), +) -> library_model.FolderListResponse: + """ + List folders for the authenticated user. + + Args: + user_id: ID of the authenticated user. + parent_id: Optional parent folder ID to filter by. + include_counts: Whether to include agent and subfolder counts. + + Returns: + A FolderListResponse containing folders. + """ + try: + folders = await library_db.list_folders( + user_id=user_id, + parent_id=parent_id, + include_counts=include_counts, + ) + return library_model.FolderListResponse( + folders=folders, + pagination=library_model.Pagination( + total_items=len(folders), + total_pages=1, + current_page=1, + page_size=len(folders), + ), + ) + except Exception as e: + logger.error(f"Could not list folders for user #{user_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e + + +@router.get( + "/tree", + summary="Get Folder Tree", + response_model=library_model.FolderTreeResponse, + responses={ + 200: {"description": "Folder tree structure"}, + 500: {"description": "Server error"}, + }, +) +async def get_folder_tree( + user_id: str = Security(autogpt_auth_lib.get_user_id), +) -> library_model.FolderTreeResponse: + """ + Get the full folder tree for the authenticated user. + + Args: + user_id: ID of the authenticated user. + + Returns: + A FolderTreeResponse containing the nested folder structure. + """ + try: + tree = await library_db.get_folder_tree(user_id=user_id) + return library_model.FolderTreeResponse(tree=tree) + except Exception as e: + logger.error(f"Could not get folder tree for user #{user_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e + + +@router.get( + "/{folder_id}", + summary="Get Folder", + response_model=library_model.LibraryFolder, + responses={ + 200: {"description": "Folder details"}, + 404: {"description": "Folder not found"}, + 500: {"description": "Server error"}, + }, +) +async def get_folder( + folder_id: str, + user_id: str = Security(autogpt_auth_lib.get_user_id), +) -> library_model.LibraryFolder: + """ + Get a specific folder. + + Args: + folder_id: ID of the folder to retrieve. + user_id: ID of the authenticated user. + + Returns: + The requested LibraryFolder. + """ + try: + return await library_db.get_folder(folder_id=folder_id, user_id=user_id) + except NotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except Exception as e: + logger.error(f"Could not get folder #{folder_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e + + +@router.post( + "", + summary="Create Folder", + status_code=status.HTTP_201_CREATED, + response_model=library_model.LibraryFolder, + responses={ + 201: {"description": "Folder created successfully"}, + 400: {"description": "Validation error"}, + 404: {"description": "Parent folder not found"}, + 409: {"description": "Folder name conflict"}, + 500: {"description": "Server error"}, + }, +) +async def create_folder( + payload: library_model.FolderCreateRequest, + user_id: str = Security(autogpt_auth_lib.get_user_id), +) -> library_model.LibraryFolder: + """ + Create a new folder. + + Args: + payload: The folder creation request. + user_id: ID of the authenticated user. + + Returns: + The created LibraryFolder. + """ + try: + return await library_db.create_folder( + user_id=user_id, + name=payload.name, + parent_id=payload.parent_id, + icon=payload.icon, + color=payload.color, + ) + except library_db.FolderValidationError as e: + if "already exists" in str(e): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) from e + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + except NotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except DatabaseError as e: + logger.error(f"Database error creating folder: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e + + +@router.patch( + "/{folder_id}", + summary="Update Folder", + response_model=library_model.LibraryFolder, + responses={ + 200: {"description": "Folder updated successfully"}, + 400: {"description": "Validation error"}, + 404: {"description": "Folder not found"}, + 409: {"description": "Folder name conflict"}, + 500: {"description": "Server error"}, + }, +) +async def update_folder( + folder_id: str, + payload: library_model.FolderUpdateRequest, + user_id: str = Security(autogpt_auth_lib.get_user_id), +) -> library_model.LibraryFolder: + """ + Update a folder's properties. + + Args: + folder_id: ID of the folder to update. + payload: The folder update request. + user_id: ID of the authenticated user. + + Returns: + The updated LibraryFolder. + """ + try: + return await library_db.update_folder( + folder_id=folder_id, + user_id=user_id, + name=payload.name, + icon=payload.icon, + color=payload.color, + ) + except library_db.FolderValidationError as e: + if "already exists" in str(e): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) from e + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + except NotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except DatabaseError as e: + logger.error(f"Database error updating folder #{folder_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e + + +@router.post( + "/{folder_id}/move", + summary="Move Folder", + response_model=library_model.LibraryFolder, + responses={ + 200: {"description": "Folder moved successfully"}, + 400: {"description": "Validation error (circular reference, depth exceeded)"}, + 404: {"description": "Folder or target parent not found"}, + 409: {"description": "Folder name conflict in target location"}, + 500: {"description": "Server error"}, + }, +) +async def move_folder( + folder_id: str, + payload: library_model.FolderMoveRequest, + user_id: str = Security(autogpt_auth_lib.get_user_id), +) -> library_model.LibraryFolder: + """ + Move a folder to a new parent. + + Args: + folder_id: ID of the folder to move. + payload: The move request with target parent. + user_id: ID of the authenticated user. + + Returns: + The moved LibraryFolder. + """ + try: + return await library_db.move_folder( + folder_id=folder_id, + user_id=user_id, + target_parent_id=payload.target_parent_id, + ) + except library_db.FolderValidationError as e: + if "already exists" in str(e): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) from e + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + except NotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except DatabaseError as e: + logger.error(f"Database error moving folder #{folder_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e + + +@router.delete( + "/{folder_id}", + summary="Delete Folder", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + 204: {"description": "Folder deleted successfully"}, + 404: {"description": "Folder not found"}, + 500: {"description": "Server error"}, + }, +) +async def delete_folder( + folder_id: str, + user_id: str = Security(autogpt_auth_lib.get_user_id), +) -> Response: + """ + Soft-delete a folder and all its contents. + + Args: + folder_id: ID of the folder to delete. + user_id: ID of the authenticated user. + + Returns: + 204 No Content if successful. + """ + try: + await library_db.delete_folder( + folder_id=folder_id, + user_id=user_id, + soft_delete=True, + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + except NotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except DatabaseError as e: + logger.error(f"Database error deleting folder #{folder_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e + + +# === Bulk Agent Operations === + + +@router.post( + "/agents/bulk-move", + summary="Bulk Move Agents", + response_model=list[library_model.LibraryAgent], + responses={ + 200: {"description": "Agents moved successfully"}, + 404: {"description": "Folder not found"}, + 500: {"description": "Server error"}, + }, +) +async def bulk_move_agents( + payload: library_model.BulkMoveAgentsRequest, + user_id: str = Security(autogpt_auth_lib.get_user_id), +) -> list[library_model.LibraryAgent]: + """ + Move multiple agents to a folder. + + Args: + payload: The bulk move request with agent IDs and target folder. + user_id: ID of the authenticated user. + + Returns: + The updated LibraryAgents. + """ + try: + return await library_db.bulk_move_agents_to_folder( + agent_ids=payload.agent_ids, + folder_id=payload.folder_id, + user_id=user_id, + ) + except NotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except DatabaseError as e: + logger.error(f"Database error bulk moving agents: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e diff --git a/autogpt_platform/backend/backend/data/includes.py b/autogpt_platform/backend/backend/data/includes.py index bc3d5c9dd1..b970c09f1f 100644 --- a/autogpt_platform/backend/backend/data/includes.py +++ b/autogpt_platform/backend/backend/data/includes.py @@ -105,6 +105,7 @@ def library_agent_include( """ result: prisma.types.LibraryAgentInclude = { "Creator": True, # Always needed for creator info + "Folder": True, # Always needed for folder info } # Build AgentGraph include based on requested options diff --git a/autogpt_platform/backend/migrations/20260123110033_add_folders_in_library/migration.sql b/autogpt_platform/backend/migrations/20260123110033_add_folders_in_library/migration.sql new file mode 100644 index 0000000000..130b7fb8c4 --- /dev/null +++ b/autogpt_platform/backend/migrations/20260123110033_add_folders_in_library/migration.sql @@ -0,0 +1,50 @@ +/* + Warnings: + + - You are about to drop the column `search` on the `StoreListingVersion` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "UnifiedContentEmbedding_search_idx"; + +-- AlterTable +ALTER TABLE "LibraryAgent" ADD COLUMN "folderId" TEXT; + +-- AlterTable +ALTER TABLE "StoreListingVersion" DROP COLUMN "search"; + +-- CreateTable +CREATE TABLE "LibraryFolder" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "icon" TEXT, + "color" TEXT, + "parentId" TEXT, + "isDeleted" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "LibraryFolder_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "LibraryFolder_userId_isDeleted_idx" ON "LibraryFolder"("userId", "isDeleted"); + +-- CreateIndex +CREATE INDEX "LibraryFolder_parentId_idx" ON "LibraryFolder"("parentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LibraryFolder_userId_parentId_name_key" ON "LibraryFolder"("userId", "parentId", "name"); + +-- CreateIndex +CREATE INDEX "LibraryAgent_folderId_idx" ON "LibraryAgent"("folderId"); + +-- AddForeignKey +ALTER TABLE "LibraryAgent" ADD CONSTRAINT "LibraryAgent_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "LibraryFolder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LibraryFolder" ADD CONSTRAINT "LibraryFolder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LibraryFolder" ADD CONSTRAINT "LibraryFolder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "LibraryFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index 2da898a7ce..3e683815bc 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -51,6 +51,7 @@ model User { ChatSessions ChatSession[] AgentPresets AgentPreset[] LibraryAgents LibraryAgent[] + LibraryFolders LibraryFolder[] Profile Profile[] UserOnboarding UserOnboarding? @@ -395,6 +396,9 @@ model LibraryAgent { creatorId String? Creator Profile? @relation(fields: [creatorId], references: [id]) + folderId String? + Folder LibraryFolder? @relation(fields: [folderId], references: [id], onDelete: Restrict) + useGraphIsActiveVersion Boolean @default(false) isFavorite Boolean @default(false) @@ -407,6 +411,32 @@ model LibraryAgent { @@unique([userId, agentGraphId, agentGraphVersion]) @@index([agentGraphId, agentGraphVersion]) @@index([creatorId]) + @@index([folderId]) +} + +model LibraryFolder { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + userId String + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + + name String + icon String? + color String? + + parentId String? + Parent LibraryFolder? @relation("FolderHierarchy", fields: [parentId], references: [id], onDelete: Cascade) + Children LibraryFolder[] @relation("FolderHierarchy") + + isDeleted Boolean @default(false) + + LibraryAgents LibraryAgent[] + + @@unique([userId, parentId, name]) // Name unique per parent per user + @@index([userId, isDeleted]) + @@index([parentId]) } ////////////////////////////////////////////////////////////