diff --git a/autogpt_platform/backend/.env.example b/autogpt_platform/backend/.env.example index 8b28244f42..7733cb7db9 100644 --- a/autogpt_platform/backend/.env.example +++ b/autogpt_platform/backend/.env.example @@ -41,6 +41,9 @@ RABBITMQ_PORT=5672 RABBITMQ_DEFAULT_USER=rabbitmq_user_default RABBITMQ_DEFAULT_PASS=k0VMxyIJF9S35f3x2uaw5IWAl6Y536O7 +## GCS bucket is required for marketplace and library functionality +MEDIA_GCS_BUCKET_NAME= + ## For local development, you may need to set FRONTEND_BASE_URL for the OAuth flow ## for integrations to work. Defaults to the value of PLATFORM_BASE_URL if not set. # FRONTEND_BASE_URL=http://localhost:3000 diff --git a/autogpt_platform/backend/backend/server/v2/library/db.py b/autogpt_platform/backend/backend/server/v2/library/db.py index da106a9081..a2dd0062e2 100644 --- a/autogpt_platform/backend/backend/server/v2/library/db.py +++ b/autogpt_platform/backend/backend/server/v2/library/db.py @@ -1,27 +1,58 @@ import logging +from typing import Optional +import fastapi import prisma.errors import prisma.fields import prisma.models import prisma.types +import backend.data.includes import backend.server.model import backend.server.v2.library.model as library_model import backend.server.v2.store.exceptions as store_exceptions +import backend.server.v2.store.image_gen as store_image_gen +import backend.server.v2.store.media as store_media logger = logging.getLogger(__name__) async def get_library_agents( - user_id: str, search_query: str | None = None -) -> list[library_model.LibraryAgent]: + user_id: str, + search_term: Optional[str] = None, + sort_by: library_model.LibraryAgentSort = library_model.LibraryAgentSort.UPDATED_AT, + page: int = 1, + page_size: int = 50, +) -> library_model.LibraryAgentResponse: + """ + Retrieves a paginated list of LibraryAgent records for a given user. + + Args: + user_id: The ID of the user whose LibraryAgents we want to retrieve. + search_term: Optional string to filter agents by name/description. + sort_by: Sorting field (createdAt, updatedAt, isFavorite, isCreatedByUser). + page: Current page (1-indexed). + page_size: Number of items per page. + + Returns: + A LibraryAgentResponse containing the list of agents and pagination details. + + Raises: + DatabaseError: If there is an issue fetching from Prisma. + """ logger.debug( - f"Fetching library agents for user_id={user_id} search_query={search_query}" + f"Fetching library agents for user_id={user_id}, " + f"search_term={repr(search_term)}, " + f"sort_by={sort_by}, page={page}, page_size={page_size}" ) - if search_query and len(search_query.strip()) > 100: - logger.warning(f"Search query too long: {search_query}") - raise store_exceptions.DatabaseError("Search query is too long.") + if page < 1 or page_size < 1: + logger.warning(f"Invalid pagination: page={page}, page_size={page_size}") + raise store_exceptions.DatabaseError("Invalid pagination input") + + if search_term and len(search_term.strip()) > 100: + logger.warning(f"Search term too long: {repr(search_term)}") + raise store_exceptions.DatabaseError("Search term is too long") where_clause: prisma.types.LibraryAgentWhereInput = { "userId": user_id, @@ -29,69 +60,167 @@ async def get_library_agents( "isArchived": False, } - if search_query: + # Build search filter if applicable + if search_term: where_clause["OR"] = [ { "Agent": { - "is": {"name": {"contains": search_query, "mode": "insensitive"}} + "is": {"name": {"contains": search_term, "mode": "insensitive"}} } }, { "Agent": { "is": { - "description": {"contains": search_query, "mode": "insensitive"} + "description": {"contains": search_term, "mode": "insensitive"} } } }, ] + # Determine sorting + order_by: prisma.types.LibraryAgentOrderByInput | None = None + + if sort_by == library_model.LibraryAgentSort.CREATED_AT: + order_by = {"createdAt": "asc"} + elif sort_by == library_model.LibraryAgentSort.UPDATED_AT: + order_by = {"updatedAt": "desc"} + try: library_agents = await prisma.models.LibraryAgent.prisma().find_many( where=where_clause, include={ "Agent": { "include": { - "AgentNodes": {"include": {"Input": True, "Output": True}} + **backend.data.includes.AGENT_GRAPH_INCLUDE, + "AgentGraphExecution": {"where": {"userId": user_id}}, } - } + }, + "Creator": True, }, - order=[{"updatedAt": "desc"}], + order=order_by, + skip=(page - 1) * page_size, + take=page_size, ) - logger.debug(f"Retrieved {len(library_agents)} agents for user_id={user_id}.") - return [library_model.LibraryAgent.from_db(agent) for agent in library_agents] + agent_count = await prisma.models.LibraryAgent.prisma().count( + where=where_clause + ) + + logger.debug( + f"Retrieved {len(library_agents)} library agents for user #{user_id}" + ) + return library_model.LibraryAgentResponse( + agents=[ + library_model.LibraryAgent.from_db(agent) for agent in library_agents + ], + pagination=backend.server.model.Pagination( + total_items=agent_count, + total_pages=(agent_count + page_size - 1) // page_size, + current_page=page, + page_size=page_size, + ), + ) + except prisma.errors.PrismaError as e: logger.error(f"Database error fetching library agents: {e}") - raise store_exceptions.DatabaseError("Unable to fetch library agents.") + raise store_exceptions.DatabaseError("Failed to fetch library agents") from e async def create_library_agent( - agent_id: str, agent_version: int, user_id: str + agent_id: str, + agent_version: int, + user_id: str, ) -> prisma.models.LibraryAgent: """ - Adds an agent to the user's library (LibraryAgent table) + Adds an agent to the user's library (LibraryAgent table). + + Args: + agent_id: The ID of the agent to add. + agent_version: The version of the agent to add. + user_id: The user to whom the agent will be added. + + Returns: + The newly created LibraryAgent record. + + Raises: + AgentNotFoundError: If the specified agent does not exist. + DatabaseError: If there's an error during creation or if image generation fails. """ + logger.info( + f"Creating library agent for graph #{agent_id} v{agent_version}; " + f"user #{user_id}" + ) + + # Fetch agent graph + try: + agent = await prisma.models.AgentGraph.prisma().find_unique( + where={"graphVersionId": {"id": agent_id, "version": agent_version}} + ) + except prisma.errors.PrismaError as e: + logger.exception("Database error fetching agent") + raise store_exceptions.DatabaseError("Failed to fetch agent") from e + + if not agent: + raise store_exceptions.AgentNotFoundError( + f"Agent #{agent_id} v{agent_version} not found" + ) + + # Use .jpeg here since we are generating JPEG images + filename = f"agent_{agent_id}.jpeg" + try: + if not (image_url := await store_media.check_media_exists(user_id, filename)): + # Generate agent image as JPEG + image = await store_image_gen.generate_agent_image(agent=agent) + + # Create UploadFile with the correct filename and content_type + image_file = fastapi.UploadFile(file=image, filename=filename) + + image_url = await store_media.upload_media( + user_id=user_id, file=image_file, use_file_name=True + ) + except Exception as e: + logger.warning(f"Error generating and uploading agent image: {e}") + image_url = None try: return await prisma.models.LibraryAgent.prisma().create( data={ - "userId": user_id, - "agentId": agent_id, - "agentVersion": agent_version, - "isCreatedByUser": False, + "imageUrl": image_url, + "isCreatedByUser": (user_id == agent.userId), "useGraphIsActiveVersion": True, + "User": {"connect": {"id": user_id}}, + # "Creator": {"connect": {"id": agent.userId}}, + "Agent": { + "connect": { + "graphVersionId": {"id": agent_id, "version": agent_version} + } + }, } ) except prisma.errors.PrismaError as e: - logger.error(f"Database error creating agent to library: {str(e)}") - raise store_exceptions.DatabaseError("Failed to create agent to library") from e + logger.error(f"Database error creating agent in library: {e}") + raise store_exceptions.DatabaseError("Failed to create agent in library") from e async def update_agent_version_in_library( - user_id: str, agent_id: str, agent_version: int + user_id: str, + agent_id: str, + agent_version: int, ) -> None: """ - Updates the agent version in the library + Updates the agent version in the library if useGraphIsActiveVersion is True. + + Args: + user_id: Owner of the LibraryAgent. + agent_id: The agent's ID to update. + agent_version: The new version of the agent. + + Raises: + DatabaseError: If there's an error with the update. """ + logger.debug( + f"Updating agent version in library for user #{user_id}, " + f"agent #{agent_id} v{agent_version}" + ) try: library_agent = await prisma.models.LibraryAgent.prisma().find_first_or_raise( where={ @@ -111,7 +240,7 @@ async def update_agent_version_in_library( }, ) except prisma.errors.PrismaError as e: - logger.error(f"Database error updating agent version in library: {str(e)}") + logger.error(f"Database error updating agent version in library: {e}") raise store_exceptions.DatabaseError( "Failed to update agent version in library" ) from e @@ -126,8 +255,24 @@ async def update_library_agent( is_deleted: bool = False, ) -> None: """ - Updates the library agent with the given fields + Updates the specified LibraryAgent record. + + Args: + library_agent_id: The ID of the LibraryAgent to update. + user_id: The owner of this LibraryAgent. + auto_update_version: Whether the agent should auto-update to active version. + is_favorite: Whether this agent is marked as a favorite. + is_archived: Whether this agent is archived. + is_deleted: Whether this agent is deleted. + + Raises: + DatabaseError: If there's an error in the update operation. """ + logger.debug( + f"Updating library agent {library_agent_id} for user {user_id} with " + f"auto_update_version={auto_update_version}, is_favorite={is_favorite}, " + f"is_archived={is_archived}, is_deleted={is_deleted}" + ) try: await prisma.models.LibraryAgent.prisma().update_many( where={"id": library_agent_id, "userId": user_id}, @@ -152,75 +297,84 @@ async def delete_library_agent_by_graph_id(graph_id: str, user_id: str) -> None: where={"agentId": graph_id, "userId": user_id} ) except prisma.errors.PrismaError as e: - logger.error(f"Database error deleting library agent: {str(e)}") + logger.error(f"Database error deleting library agent: {e}") raise store_exceptions.DatabaseError("Failed to delete library agent") from e async def add_store_agent_to_library( store_listing_version_id: str, user_id: str -) -> None: +) -> library_model.LibraryAgent: """ - Finds the agent from the store listing version and adds it to the user's library (LibraryAgent table) - if they don't already have it + Adds an agent from a store listing version to the user's library if they don't already have it. + + Args: + store_listing_version_id: The ID of the store listing version containing the agent. + user_id: The user’s library to which the agent is being added. + + Returns: + The newly created LibraryAgent if successfully added, the existing corresponding one if any. + + Raises: + AgentNotFoundError: If the store listing or associated agent is not found. + DatabaseError: If there's an issue creating the LibraryAgent record. """ logger.debug( - f"Adding agent from store listing version {store_listing_version_id} to library for user {user_id}" + f"Adding agent from store listing version #{store_listing_version_id} " + f"to library for user #{user_id}" ) try: - # Get store listing version to find agent store_listing_version = ( await prisma.models.StoreListingVersion.prisma().find_unique( where={"id": store_listing_version_id}, include={"Agent": True} ) ) - if not store_listing_version or not store_listing_version.Agent: logger.warning( f"Store listing version not found: {store_listing_version_id}" ) raise store_exceptions.AgentNotFoundError( - f"Store listing version {store_listing_version_id} not found" + f"Store listing version {store_listing_version_id} not found or invalid" ) - agent = store_listing_version.Agent - - if agent.userId == user_id: + store_agent = store_listing_version.Agent + if store_agent.userId == user_id: logger.warning( - f"User {user_id} cannot add their own agent to their library" + f"User #{user_id} attempted to add their own agent to their library" ) raise store_exceptions.DatabaseError("Cannot add own agent to library") # Check if user already has this agent - existing_user_agent = await prisma.models.LibraryAgent.prisma().find_first( + existing_library_agent = await prisma.models.LibraryAgent.prisma().find_first( where={ "userId": user_id, - "agentId": agent.id, - "agentVersion": agent.version, + "agentId": store_agent.id, + "agentVersion": store_agent.version, } ) - - if existing_user_agent: + if existing_library_agent: logger.debug( - f"User {user_id} already has agent {agent.id} in their library" + f"User #{user_id} already has agent #{store_agent.id} in their library" ) - return + return library_model.LibraryAgent.from_db(existing_library_agent) # Create LibraryAgent entry - await prisma.models.LibraryAgent.prisma().create( + added_agent = await prisma.models.LibraryAgent.prisma().create( data={ "userId": user_id, - "agentId": agent.id, - "agentVersion": agent.version, + "agentId": store_agent.id, + "agentVersion": store_agent.version, "isCreatedByUser": False, } ) - logger.debug(f"Added agent {agent.id} to library for user {user_id}") + logger.debug(f"Added agent #{store_agent.id} to library for user #{user_id}") + return library_model.LibraryAgent.from_db(added_agent) except store_exceptions.AgentNotFoundError: + # Reraise for external handling. raise except prisma.errors.PrismaError as e: - logger.error(f"Database error adding agent to library: {str(e)}") + logger.error(f"Database error adding agent to library: {e}") raise store_exceptions.DatabaseError("Failed to add agent to library") from e @@ -232,20 +386,44 @@ async def add_store_agent_to_library( async def get_presets( user_id: str, page: int, page_size: int ) -> library_model.LibraryAgentPresetResponse: + """ + Retrieves a paginated list of AgentPresets for the specified user. + + Args: + user_id: The user ID whose presets are being retrieved. + page: The current page index (0-based or 1-based, clarify in your domain). + page_size: Number of items to retrieve per page. + + Returns: + A LibraryAgentPresetResponse containing a list of presets and pagination info. + + Raises: + DatabaseError: If there's a database error during the operation. + """ + logger.debug( + f"Fetching presets for user #{user_id}, page={page}, page_size={page_size}" + ) + + if page < 0 or page_size < 1: + logger.warning( + "Invalid pagination input: page=%d, page_size=%d", page, page_size + ) + raise store_exceptions.DatabaseError("Invalid pagination parameters") + try: - presets = await prisma.models.AgentPreset.prisma().find_many( + presets_records = await prisma.models.AgentPreset.prisma().find_many( where={"userId": user_id}, skip=page * page_size, take=page_size, ) - total_items = await prisma.models.AgentPreset.prisma().count( - where={"userId": user_id}, + where={"userId": user_id} ) total_pages = (total_items + page_size - 1) // page_size presets = [ - library_model.LibraryAgentPreset.from_db(preset) for preset in presets + library_model.LibraryAgentPreset.from_db(preset) + for preset in presets_records ] return library_model.LibraryAgentPresetResponse( @@ -259,34 +437,67 @@ async def get_presets( ) except prisma.errors.PrismaError as e: - logger.error(f"Database error getting presets: {str(e)}") + logger.error(f"Database error getting presets: {e}") raise store_exceptions.DatabaseError("Failed to fetch presets") from e async def get_preset( user_id: str, preset_id: str ) -> library_model.LibraryAgentPreset | None: + """ + Retrieves a single AgentPreset by its ID for a given user. + + Args: + user_id: The user that owns the preset. + preset_id: The ID of the preset. + + Returns: + A LibraryAgentPreset if it exists and matches the user, otherwise None. + + Raises: + DatabaseError: If there's a database error during the fetch. + """ + logger.debug(f"Fetching preset #{preset_id} for user #{user_id}") try: preset = await prisma.models.AgentPreset.prisma().find_unique( - where={"id": preset_id}, include={"InputPresets": True} + where={"id": preset_id}, + include={"InputPresets": True}, ) if not preset or preset.userId != user_id: return None return library_model.LibraryAgentPreset.from_db(preset) except prisma.errors.PrismaError as e: - logger.error(f"Database error getting preset: {str(e)}") + logger.error(f"Database error getting preset: {e}") raise store_exceptions.DatabaseError("Failed to fetch preset") from e async def upsert_preset( user_id: str, preset: library_model.CreateLibraryAgentPresetRequest, - preset_id: str | None = None, + preset_id: Optional[str] = None, ) -> library_model.LibraryAgentPreset: + """ + Creates or updates an AgentPreset for a user. + + Args: + user_id: The ID of the user creating/updating the preset. + preset: The preset data used for creation or update. + preset_id: An optional preset ID to update; if None, a new preset is created. + + Returns: + The newly created or updated LibraryAgentPreset. + + Raises: + DatabaseError: If there's a database error in creating or updating the preset. + ValueError: If attempting to update a non-existent preset. + """ + logger.debug( + f"Upserting preset #{preset_id} ({repr(preset.name)}) for user #{user_id}", + ) try: if preset_id: # Update existing preset - new_preset = await prisma.models.AgentPreset.prisma().update( + updated = await prisma.models.AgentPreset.prisma().update( where={"id": preset_id}, data={ "name": preset.name, @@ -301,8 +512,9 @@ async def upsert_preset( }, include={"InputPresets": True}, ) - if not new_preset: + if not updated: raise ValueError(f"AgentPreset #{preset_id} not found") + return library_model.LibraryAgentPreset.from_db(updated) else: # Create new preset new_preset = await prisma.models.AgentPreset.prisma().create( @@ -324,16 +536,27 @@ async def upsert_preset( ) return library_model.LibraryAgentPreset.from_db(new_preset) except prisma.errors.PrismaError as e: - logger.error(f"Database error creating preset: {str(e)}") + logger.error(f"Database error upserting preset: {e}") raise store_exceptions.DatabaseError("Failed to create preset") from e async def delete_preset(user_id: str, preset_id: str) -> None: + """ + Soft-deletes a preset by marking it as isDeleted = True. + + Args: + user_id: The user that owns the preset. + preset_id: The ID of the preset to delete. + + Raises: + DatabaseError: If there's a database error during deletion. + """ + logger.info(f"Deleting preset {preset_id} for user {user_id}") try: await prisma.models.AgentPreset.prisma().update_many( where={"id": preset_id, "userId": user_id}, data={"isDeleted": True}, ) except prisma.errors.PrismaError as e: - logger.error(f"Database error deleting preset: {str(e)}") + logger.error(f"Database error deleting preset: {e}") raise store_exceptions.DatabaseError("Failed to delete preset") from e diff --git a/autogpt_platform/backend/backend/server/v2/library/db_test.py b/autogpt_platform/backend/backend/server/v2/library/db_test.py index 17e3bdb4ef..26bc0ff42b 100644 --- a/autogpt_platform/backend/backend/server/v2/library/db_test.py +++ b/autogpt_platform/backend/backend/server/v2/library/db_test.py @@ -5,7 +5,6 @@ import prisma.models import pytest from prisma import Prisma -import backend.data.includes import backend.server.v2.library.db as db import backend.server.v2.store.exceptions @@ -72,21 +71,24 @@ async def test_get_library_agents(mocker): mock_library_agent.return_value.find_many = mocker.AsyncMock( return_value=mock_library_agents ) + mock_library_agent.return_value.count = mocker.AsyncMock(return_value=1) # Call function result = await db.get_library_agents("test-user") # Verify results - assert len(result) == 1 - assert result[0].id == "ua1" - assert result[0].name == "Test Agent 2" - assert result[0].description == "Test Description 2" - assert result[0].is_created_by_user is False - assert result[0].is_latest_version is True - assert result[0].is_favorite is False - assert result[0].agent_id == "agent2" - assert result[0].agent_version == 1 - assert result[0].preset_id is None + assert len(result.agents) == 1 + assert result.agents[0].id == "ua1" + assert result.agents[0].name == "Test Agent 2" + assert result.agents[0].description == "Test Description 2" + assert result.agents[0].agent_id == "agent2" + assert result.agents[0].agent_version == 1 + assert result.agents[0].can_access_graph is False + assert result.agents[0].is_latest_version is True + assert result.pagination.total_items == 1 + assert result.pagination.total_pages == 1 + assert result.pagination.current_page == 1 + assert result.pagination.page_size == 50 @pytest.mark.asyncio diff --git a/autogpt_platform/backend/backend/server/v2/library/model.py b/autogpt_platform/backend/backend/server/v2/library/model.py index fc2c6c6757..44a1fc3f1c 100644 --- a/autogpt_platform/backend/backend/server/v2/library/model.py +++ b/autogpt_platform/backend/backend/server/v2/library/model.py @@ -1,6 +1,8 @@ import datetime +from enum import Enum from typing import Any +import prisma.enums import prisma.models import pydantic @@ -9,13 +11,29 @@ import backend.data.graph as graph_model import backend.server.model as server_model +class LibraryAgentStatus(str, Enum): + COMPLETED = "COMPLETED" # All runs completed + HEALTHY = "HEALTHY" # Agent is running (not all runs have completed) + WAITING = "WAITING" # Agent is queued or waiting to start + ERROR = "ERROR" # Agent is in an error state + + class LibraryAgent(pydantic.BaseModel): - id: str # Changed from agent_id to match GraphMeta + """ + Represents an agent in the library, including metadata for display and + user interaction within the system. + """ + id: str agent_id: str - agent_version: int # Changed from agent_version to match GraphMeta + agent_version: int - preset_id: str | None + image_url: str | None + + creator_name: str + creator_image_url: str + + status: LibraryAgentStatus updated_at: datetime.datetime @@ -24,47 +42,135 @@ class LibraryAgent(pydantic.BaseModel): # Made input_schema and output_schema match GraphMeta's type input_schema: dict[str, Any] # Should be BlockIOObjectSubSchema in frontend - output_schema: dict[str, Any] # Should be BlockIOObjectSubSchema in frontend - is_favorite: bool - is_created_by_user: bool + # Indicates whether there's a new output (based on recent runs) + new_output: bool + # Whether the user can access the underlying graph + can_access_graph: bool + + # Indicates if this agent is the latest version is_latest_version: bool @staticmethod - def from_db(agent: prisma.models.LibraryAgent): + def from_db(agent: prisma.models.LibraryAgent) -> "LibraryAgent": + """ + Factory method that constructs a LibraryAgent from a Prisma LibraryAgent + model instance. + """ if not agent.Agent: - raise ValueError("AgentGraph is required") + raise ValueError("Associated Agent record is required.") graph = graph_model.GraphModel.from_db(agent.Agent) agent_updated_at = agent.Agent.updatedAt lib_agent_updated_at = agent.updatedAt - # Take the latest updated_at timestamp either when the graph was updated or the library agent was updated + # Compute updated_at as the latest between library agent and graph updated_at = ( max(agent_updated_at, lib_agent_updated_at) if agent_updated_at else lib_agent_updated_at ) + creator_name = "Unknown" + creator_image_url = "" + if agent.Creator: + creator_name = agent.Creator.name or "Unknown" + creator_image_url = agent.Creator.avatarUrl or "" + + # Logic to calculate status and new_output + week_ago = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + days=7 + ) + executions = agent.Agent.AgentGraphExecution or [] + status_result = _calculate_agent_status(executions, week_ago) + status = status_result.status + new_output = status_result.new_output + + # Check if user can access the graph + can_access_graph = agent.Agent.userId == agent.userId + + # Hard-coded to True until a method to check is implemented + is_latest_version = True + return LibraryAgent( id=agent.id, agent_id=agent.agentId, agent_version=agent.agentVersion, + image_url=agent.imageUrl, + creator_name=creator_name, + creator_image_url=creator_image_url, + status=status, updated_at=updated_at, name=graph.name, description=graph.description, input_schema=graph.input_schema, - output_schema=graph.output_schema, - is_favorite=agent.isFavorite, - is_created_by_user=agent.isCreatedByUser, - is_latest_version=graph.is_active, - preset_id=agent.AgentPreset.id if agent.AgentPreset else None, + new_output=new_output, + can_access_graph=can_access_graph, + is_latest_version=is_latest_version, ) +class AgentStatusResult(pydantic.BaseModel): + status: LibraryAgentStatus + new_output: bool + + +def _calculate_agent_status( + executions: list[prisma.models.AgentGraphExecution], + recent_threshold: datetime.datetime, +) -> AgentStatusResult: + """ + Helper function to determine the overall agent status and whether there + is new output (i.e., completed runs within the recent threshold). + + :param executions: A list of AgentGraphExecution objects. + :param recent_threshold: A datetime; any execution after this indicates new output. + :return: (AgentStatus, new_output_flag) + """ + + if not executions: + return AgentStatusResult(status=LibraryAgentStatus.COMPLETED, new_output=False) + + # Track how many times each execution status appears + status_counts = {status: 0 for status in prisma.enums.AgentExecutionStatus} + new_output = False + + for execution in executions: + # Check if there's a completed run more recent than `recent_threshold` + if execution.createdAt >= recent_threshold: + if execution.executionStatus == prisma.enums.AgentExecutionStatus.COMPLETED: + new_output = True + status_counts[execution.executionStatus] += 1 + + # Determine the final status based on counts + if status_counts[prisma.enums.AgentExecutionStatus.FAILED] > 0: + return AgentStatusResult(status=LibraryAgentStatus.ERROR, new_output=new_output) + elif status_counts[prisma.enums.AgentExecutionStatus.QUEUED] > 0: + return AgentStatusResult( + status=LibraryAgentStatus.WAITING, new_output=new_output + ) + elif status_counts[prisma.enums.AgentExecutionStatus.RUNNING] > 0: + return AgentStatusResult( + status=LibraryAgentStatus.HEALTHY, new_output=new_output + ) + else: + return AgentStatusResult( + status=LibraryAgentStatus.COMPLETED, new_output=new_output + ) + + +class LibraryAgentResponse(pydantic.BaseModel): + """Response schema for a list of library agents and pagination info.""" + + agents: list[LibraryAgent] + pagination: server_model.Pagination + + class LibraryAgentPreset(pydantic.BaseModel): + """Represents a preset configuration for a library agent.""" + id: str updated_at: datetime.datetime @@ -78,14 +184,14 @@ class LibraryAgentPreset(pydantic.BaseModel): inputs: block_model.BlockInput - @staticmethod - def from_db(preset: prisma.models.AgentPreset): + @classmethod + def from_db(cls, preset: prisma.models.AgentPreset) -> "LibraryAgentPreset": input_data: block_model.BlockInput = {} for preset_input in preset.InputPresets or []: input_data[preset_input.name] = preset_input.data - return LibraryAgentPreset( + return cls( id=preset.id, updated_at=preset.updatedAt, agent_id=preset.agentId, @@ -98,14 +204,52 @@ class LibraryAgentPreset(pydantic.BaseModel): class LibraryAgentPresetResponse(pydantic.BaseModel): + """Response schema for a list of agent presets and pagination info.""" + presets: list[LibraryAgentPreset] pagination: server_model.Pagination class CreateLibraryAgentPresetRequest(pydantic.BaseModel): + """ + Request model used when creating a new preset for a library agent. + """ + name: str description: str inputs: block_model.BlockInput agent_id: str agent_version: int is_active: bool + + +class LibraryAgentFilter(str, Enum): + """Possible filters for searching library agents.""" + + IS_FAVOURITE = "isFavourite" + IS_CREATED_BY_USER = "isCreatedByUser" + + +class LibraryAgentSort(str, Enum): + """Possible sort options for sorting library agents.""" + + CREATED_AT = "createdAt" + UPDATED_AT = "updatedAt" + + +class LibraryAgentUpdateRequest(pydantic.BaseModel): + """ + Schema for updating a library agent via PUT. + + Includes flags for auto-updating version, marking as favorite, + archiving, or deleting. + """ + + auto_update_version: bool = pydantic.Field( + False, description="Auto-update the agent version" + ) + is_favorite: bool = pydantic.Field( + False, description="Mark the agent as a favorite" + ) + is_archived: bool = pydantic.Field(False, description="Archive the agent") + is_deleted: bool = pydantic.Field(False, description="Delete the agent") diff --git a/autogpt_platform/backend/backend/server/v2/library/model_test.py b/autogpt_platform/backend/backend/server/v2/library/model_test.py index 0eb700a7b3..c140cd9ca6 100644 --- a/autogpt_platform/backend/backend/server/v2/library/model_test.py +++ b/autogpt_platform/backend/backend/server/v2/library/model_test.py @@ -3,147 +3,10 @@ import datetime import prisma.fields import prisma.models -import backend.data.block -import backend.server.model -import backend.server.v2.library.model +import backend.server.v2.library.model as library_model -def test_library_agent(): - agent = backend.server.v2.library.model.LibraryAgent( - id="test-agent-123", - agent_id="agent-123", - agent_version=1, - preset_id=None, - updated_at=datetime.datetime.now(), - name="Test Agent", - description="Test description", - input_schema={"type": "object", "properties": {}}, - output_schema={"type": "object", "properties": {}}, - is_favorite=False, - is_created_by_user=False, - is_latest_version=True, - ) - assert agent.id == "test-agent-123" - assert agent.agent_id == "agent-123" - assert agent.agent_version == 1 - assert agent.name == "Test Agent" - assert agent.description == "Test description" - assert agent.is_favorite is False - assert agent.is_created_by_user is False - assert agent.is_latest_version is True - assert agent.input_schema == {"type": "object", "properties": {}} - assert agent.output_schema == {"type": "object", "properties": {}} - - -def test_library_agent_with_user_created(): - agent = backend.server.v2.library.model.LibraryAgent( - id="user-agent-456", - agent_id="agent-456", - agent_version=2, - preset_id=None, - updated_at=datetime.datetime.now(), - name="User Created Agent", - description="An agent created by the user", - input_schema={"type": "object", "properties": {}}, - output_schema={"type": "object", "properties": {}}, - is_favorite=False, - is_created_by_user=True, - is_latest_version=True, - ) - assert agent.id == "user-agent-456" - assert agent.agent_id == "agent-456" - assert agent.agent_version == 2 - assert agent.name == "User Created Agent" - assert agent.description == "An agent created by the user" - assert agent.is_favorite is False - assert agent.is_created_by_user is True - assert agent.is_latest_version is True - assert agent.input_schema == {"type": "object", "properties": {}} - assert agent.output_schema == {"type": "object", "properties": {}} - - -def test_library_agent_preset(): - preset = backend.server.v2.library.model.LibraryAgentPreset( - id="preset-123", - name="Test Preset", - description="Test preset description", - agent_id="test-agent-123", - agent_version=1, - is_active=True, - inputs={ - "dictionary": {"key1": "Hello", "key2": "World"}, - "selected_value": "key2", - }, - updated_at=datetime.datetime.now(), - ) - assert preset.id == "preset-123" - assert preset.name == "Test Preset" - assert preset.description == "Test preset description" - assert preset.agent_id == "test-agent-123" - assert preset.agent_version == 1 - assert preset.is_active is True - assert preset.inputs == { - "dictionary": {"key1": "Hello", "key2": "World"}, - "selected_value": "key2", - } - - -def test_library_agent_preset_response(): - preset = backend.server.v2.library.model.LibraryAgentPreset( - id="preset-123", - name="Test Preset", - description="Test preset description", - agent_id="test-agent-123", - agent_version=1, - is_active=True, - inputs={ - "dictionary": {"key1": "Hello", "key2": "World"}, - "selected_value": "key2", - }, - updated_at=datetime.datetime.now(), - ) - - pagination = backend.server.model.Pagination( - total_items=1, total_pages=1, current_page=1, page_size=10 - ) - - response = backend.server.v2.library.model.LibraryAgentPresetResponse( - presets=[preset], pagination=pagination - ) - - assert len(response.presets) == 1 - assert response.presets[0].id == "preset-123" - assert response.pagination.total_items == 1 - assert response.pagination.total_pages == 1 - assert response.pagination.current_page == 1 - assert response.pagination.page_size == 10 - - -def test_create_library_agent_preset_request(): - request = backend.server.v2.library.model.CreateLibraryAgentPresetRequest( - name="New Preset", - description="New preset description", - agent_id="agent-123", - agent_version=1, - is_active=True, - inputs={ - "dictionary": {"key1": "Hello", "key2": "World"}, - "selected_value": "key2", - }, - ) - - assert request.name == "New Preset" - assert request.description == "New preset description" - assert request.agent_id == "agent-123" - assert request.agent_version == 1 - assert request.is_active is True - assert request.inputs == { - "dictionary": {"key1": "Hello", "key2": "World"}, - "selected_value": "key2", - } - - -def test_library_agent_from_db(): +def test_agent_preset_from_db(): # Create mock DB agent db_agent = prisma.models.AgentPreset( id="test-agent-123", @@ -167,7 +30,7 @@ def test_library_agent_from_db(): ) # Convert to LibraryAgentPreset - agent = backend.server.v2.library.model.LibraryAgentPreset.from_db(db_agent) + agent = library_model.LibraryAgentPreset.from_db(db_agent) assert agent.id == "test-agent-123" assert agent.agent_version == 1 diff --git a/autogpt_platform/backend/backend/server/v2/library/routes/agents.py b/autogpt_platform/backend/backend/server/v2/library/routes/agents.py index ba3523c552..f297374e1d 100644 --- a/autogpt_platform/backend/backend/server/v2/library/routes/agents.py +++ b/autogpt_platform/backend/backend/server/v2/library/routes/agents.py @@ -1,8 +1,9 @@ import logging -from typing import Annotated, Sequence +from typing import Optional import autogpt_libs.auth as autogpt_auth_lib -import fastapi +from fastapi import APIRouter, Body, Depends, HTTPException, Query, status +from fastapi.responses import JSONResponse import backend.server.v2.library.db as library_db import backend.server.v2.library.model as library_model @@ -10,129 +11,174 @@ import backend.server.v2.store.exceptions as store_exceptions logger = logging.getLogger(__name__) -router = fastapi.APIRouter() +router = APIRouter( + prefix="/agents", + tags=["library", "private"], + dependencies=[Depends(autogpt_auth_lib.auth_middleware)], +) @router.get( - "/agents", - tags=["library", "private"], - dependencies=[fastapi.Depends(autogpt_auth_lib.auth_middleware)], + "", + responses={ + 500: {"description": "Server error", "content": {"application/json": {}}}, + }, ) async def get_library_agents( - user_id: Annotated[str, fastapi.Depends(autogpt_auth_lib.depends.get_user_id)] -) -> Sequence[library_model.LibraryAgent]: + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), + search_term: Optional[str] = Query( + None, description="Search term to filter agents" + ), + sort_by: library_model.LibraryAgentSort = Query( + library_model.LibraryAgentSort.UPDATED_AT, + description="Sort results by criteria", + ), + page: int = Query( + 1, + ge=1, + description="Page number to retrieve (must be >= 1)", + ), + page_size: int = Query( + 15, + ge=1, + description="Number of agents per page (must be >= 1)", + ), +) -> library_model.LibraryAgentResponse: """ - Get all agents in the user's library, including both created and saved agents. + Get all agents in the user's library (both created and saved). + + Args: + user_id: ID of the authenticated user. + search_term: Optional search term to filter agents by name/description. + filter_by: List of filters to apply (favorites, created by user). + sort_by: List of sorting criteria (created date, updated date). + page: Page number to retrieve. + page_size: Number of agents per page. + + Returns: + A LibraryAgentResponse containing agents and pagination metadata. + + Raises: + HTTPException: If a server/database error occurs. """ try: - agents = await library_db.get_library_agents(user_id) - return agents - except Exception as e: - logger.exception(f"Exception occurred whilst getting library agents: {e}") - raise fastapi.HTTPException( - status_code=500, detail="Failed to get library agents" + return await library_db.get_library_agents( + user_id=user_id, + search_term=search_term, + sort_by=sort_by, + page=page, + page_size=page_size, ) + except Exception as e: + logger.error(f"Could not fetch library agents: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get library agents", + ) from e @router.post( - "/agents/{store_listing_version_id}", - tags=["library", "private"], - dependencies=[fastapi.Depends(autogpt_auth_lib.auth_middleware)], - status_code=201, + "", + status_code=status.HTTP_201_CREATED, + responses={ + 201: {"description": "Agent added successfully"}, + 404: {"description": "Store listing version not found"}, + 500: {"description": "Server error"}, + }, ) -async def add_agent_to_library( - store_listing_version_id: str, - user_id: Annotated[str, fastapi.Depends(autogpt_auth_lib.depends.get_user_id)], -) -> fastapi.Response: +async def add_marketplace_agent_to_library( + store_listing_version_id: str = Body(embed=True), + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> library_model.LibraryAgent: """ - Add an agent from the store to the user's library. + Add an agent from the marketplace to the user's library. Args: - store_listing_version_id (str): ID of the store listing version to add - user_id (str): ID of the authenticated user + store_listing_version_id: ID of the store listing version to add. + user_id: ID of the authenticated user. Returns: - fastapi.Response: 201 status code on success + 201 (Created) on success. Raises: - HTTPException: If there is an error adding the agent to the library + HTTPException(404): If the listing version is not found. + HTTPException(500): If a server/database error occurs. """ try: - # Use the database function to add the agent to the library - await library_db.add_store_agent_to_library(store_listing_version_id, user_id) - return fastapi.Response(status_code=201) + return await library_db.add_store_agent_to_library( + store_listing_version_id=store_listing_version_id, + user_id=user_id, + ) except store_exceptions.AgentNotFoundError: - raise fastapi.HTTPException( + logger.warning(f"Agent not found: {store_listing_version_id}") + raise HTTPException( status_code=404, detail=f"Store listing version {store_listing_version_id} not found", ) except store_exceptions.DatabaseError as e: - logger.exception(f"Database error occurred whilst adding agent to library: {e}") - raise fastapi.HTTPException( - status_code=500, detail="Failed to add agent to library" - ) + logger.error(f"Database error occurred whilst adding agent to library: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to add agent to library", + ) from e except Exception as e: - logger.exception( - f"Unexpected exception occurred whilst adding agent to library: {e}" - ) - raise fastapi.HTTPException( - status_code=500, detail="Failed to add agent to library" - ) + logger.error(f"Unexpected error while adding agent: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to add agent to library", + ) from e @router.put( - "/agents/{library_agent_id}", - tags=["library", "private"], - dependencies=[fastapi.Depends(autogpt_auth_lib.auth_middleware)], - status_code=204, + "/{library_agent_id}", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + 204: {"description": "Agent updated successfully"}, + 500: {"description": "Server error"}, + }, ) async def update_library_agent( library_agent_id: str, - user_id: Annotated[str, fastapi.Depends(autogpt_auth_lib.depends.get_user_id)], - auto_update_version: bool = False, - is_favorite: bool = False, - is_archived: bool = False, - is_deleted: bool = False, -) -> fastapi.Response: + payload: library_model.LibraryAgentUpdateRequest, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> JSONResponse: """ Update the library agent with the given fields. Args: - library_agent_id (str): ID of the library agent to update - user_id (str): ID of the authenticated user - auto_update_version (bool): Whether to auto-update the agent version - is_favorite (bool): Whether the agent is marked as favorite - is_archived (bool): Whether the agent is archived - is_deleted (bool): Whether the agent is deleted + library_agent_id: ID of the library agent to update. + payload: Fields to update (auto_update_version, is_favorite, etc.). + user_id: ID of the authenticated user. Returns: - fastapi.Response: 204 status code on success + 204 (No Content) on success. Raises: - HTTPException: If there is an error updating the library agent + HTTPException(500): If a server/database error occurs. """ try: - # Use the database function to update the library agent await library_db.update_library_agent( - library_agent_id, - user_id, - auto_update_version, - is_favorite, - is_archived, - is_deleted, + library_agent_id=library_agent_id, + user_id=user_id, + auto_update_version=payload.auto_update_version, + is_favorite=payload.is_favorite, + is_archived=payload.is_archived, + is_deleted=payload.is_deleted, + ) + return JSONResponse( + status_code=status.HTTP_204_NO_CONTENT, + content={"message": "Agent updated successfully"}, ) - return fastapi.Response(status_code=204) - except store_exceptions.DatabaseError as e: - logger.exception(f"Database error occurred whilst updating library agent: {e}") - raise fastapi.HTTPException( - status_code=500, detail="Failed to update library agent" - ) + logger.exception(f"Database error while updating library agent: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update library agent", + ) from e except Exception as e: - logger.exception( - f"Unexpected exception occurred whilst updating library agent: {e}" - ) - raise fastapi.HTTPException( - status_code=500, detail="Failed to update library agent" - ) + logger.exception(f"Unexpected error while updating library agent: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update library agent", + ) from e diff --git a/autogpt_platform/backend/backend/server/v2/library/routes/presets.py b/autogpt_platform/backend/backend/server/v2/library/routes/presets.py index ff02a394bb..73d94cab46 100644 --- a/autogpt_platform/backend/backend/server/v2/library/routes/presets.py +++ b/autogpt_platform/backend/backend/server/v2/library/routes/presets.py @@ -3,113 +3,225 @@ from typing import Annotated, Any import autogpt_libs.auth as autogpt_auth_lib import autogpt_libs.utils.cache -import fastapi +from fastapi import APIRouter, Body, Depends, HTTPException, status import backend.executor -import backend.server.v2.library.db as library_db -import backend.server.v2.library.model as library_model +import backend.server.v2.library.db as db +import backend.server.v2.library.model as models import backend.util.service logger = logging.getLogger(__name__) -router = fastapi.APIRouter() +router = APIRouter() @autogpt_libs.utils.cache.thread_cached def execution_manager_client() -> backend.executor.ExecutionManager: + """Return a cached instance of ExecutionManager client.""" return backend.util.service.get_service_client(backend.executor.ExecutionManager) -@router.get("/presets") +@router.get( + "/presets", + summary="List presets", + description="Retrieve a paginated list of presets for the current user.", +) async def get_presets( - user_id: Annotated[str, fastapi.Depends(autogpt_auth_lib.depends.get_user_id)], + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), page: int = 1, page_size: int = 10, -) -> library_model.LibraryAgentPresetResponse: +) -> models.LibraryAgentPresetResponse: + """ + Retrieve a paginated list of presets for the current user. + + Args: + user_id (str): ID of the authenticated user. + page (int): Page number for pagination. + page_size (int): Number of items per page. + + Returns: + models.LibraryAgentPresetResponse: A response containing the list of presets. + """ try: - presets = await library_db.get_presets(user_id, page, page_size) - return presets + return await db.get_presets(user_id, page, page_size) except Exception as e: - logger.exception(f"Exception occurred whilst getting presets: {e}") - raise fastapi.HTTPException(status_code=500, detail="Failed to get presets") + logger.exception(f"Exception occurred while getting presets: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get presets", + ) -@router.get("/presets/{preset_id}") +@router.get( + "/presets/{preset_id}", + summary="Get a specific preset", + description="Retrieve details for a specific preset by its ID.", +) async def get_preset( preset_id: str, - user_id: Annotated[str, fastapi.Depends(autogpt_auth_lib.depends.get_user_id)], -) -> library_model.LibraryAgentPreset: + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> models.LibraryAgentPreset: + """ + Retrieve details for a specific preset by its ID. + + Args: + preset_id (str): ID of the preset to retrieve. + user_id (str): ID of the authenticated user. + + Returns: + models.LibraryAgentPreset: The preset details. + + Raises: + HTTPException: If the preset is not found or an error occurs. + """ try: - preset = await library_db.get_preset(user_id, preset_id) + preset = await db.get_preset(user_id, preset_id) if not preset: - raise fastapi.HTTPException( - status_code=404, + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=f"Preset {preset_id} not found", ) return preset except Exception as e: logger.exception(f"Exception occurred whilst getting preset: {e}") - raise fastapi.HTTPException(status_code=500, detail="Failed to get preset") - - -@router.post("/presets") -async def create_preset( - preset: library_model.CreateLibraryAgentPresetRequest, - user_id: Annotated[str, fastapi.Depends(autogpt_auth_lib.depends.get_user_id)], -) -> library_model.LibraryAgentPreset: - try: - return await library_db.upsert_preset(user_id, preset) - except Exception as e: - logger.exception(f"Exception occurred whilst creating preset: {e}") - raise fastapi.HTTPException(status_code=500, detail="Failed to create preset") - - -@router.put("/presets/{preset_id}") -async def update_preset( - preset_id: str, - preset: library_model.CreateLibraryAgentPresetRequest, - user_id: Annotated[str, fastapi.Depends(autogpt_auth_lib.depends.get_user_id)], -) -> library_model.LibraryAgentPreset: - try: - return await library_db.upsert_preset(user_id, preset, preset_id) - except Exception as e: - logger.exception(f"Exception occurred whilst updating preset: {e}") - raise fastapi.HTTPException(status_code=500, detail="Failed to update preset") - - -@router.delete("/presets/{preset_id}") -async def delete_preset( - preset_id: str, - user_id: Annotated[str, fastapi.Depends(autogpt_auth_lib.depends.get_user_id)], -): - try: - await library_db.delete_preset(user_id, preset_id) - return fastapi.Response(status_code=204) - except Exception as e: - logger.exception(f"Exception occurred whilst deleting preset: {e}") - raise fastapi.HTTPException(status_code=500, detail="Failed to delete preset") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get preset", + ) @router.post( - path="/presets/{preset_id}/execute", + "/presets", + summary="Create a new preset", + description="Create a new preset for the current user.", +) +async def create_preset( + preset: models.CreateLibraryAgentPresetRequest, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> models.LibraryAgentPreset: + """ + Create a new library agent preset. Automatically corrects node_input format if needed. + + Args: + preset (models.CreateLibraryAgentPresetRequest): The preset data to create. + user_id (str): ID of the authenticated user. + + Returns: + models.LibraryAgentPreset: The created preset. + + Raises: + HTTPException: If an error occurs while creating the preset. + """ + try: + return await db.upsert_preset(user_id, preset) + except Exception as e: + logger.exception(f"Exception occurred while creating preset: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create preset", + ) + + +@router.put( + "/presets/{preset_id}", + summary="Update an existing preset", + description="Update an existing preset by its ID.", +) +async def update_preset( + preset_id: str, + preset: models.CreateLibraryAgentPresetRequest, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> models.LibraryAgentPreset: + """ + Update an existing library agent preset. If the preset doesn't exist, it may be created. + + Args: + preset_id (str): ID of the preset to update. + preset (models.CreateLibraryAgentPresetRequest): The preset data to update. + user_id (str): ID of the authenticated user. + + Returns: + models.LibraryAgentPreset: The updated preset. + + Raises: + HTTPException: If an error occurs while updating the preset. + """ + try: + return await db.upsert_preset(user_id, preset, preset_id) + except Exception as e: + logger.exception(f"Exception occurred whilst updating preset: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update preset", + ) + + +@router.delete( + "/presets/{preset_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a preset", + description="Delete an existing preset by its ID.", +) +async def delete_preset( + preset_id: str, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> None: + """ + Delete a preset by its ID. Returns 204 No Content on success. + + Args: + preset_id (str): ID of the preset to delete. + user_id (str): ID of the authenticated user. + + Raises: + HTTPException: If an error occurs while deleting the preset. + """ + try: + await db.delete_preset(user_id, preset_id) + except Exception as e: + logger.exception(f"Exception occurred whilst deleting preset: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete preset", + ) + + +@router.post( + "/presets/{preset_id}/execute", tags=["presets"], - dependencies=[fastapi.Depends(autogpt_auth_lib.auth_middleware)], + summary="Execute a preset", + description="Execute a preset with the given graph and node input for the current user.", ) async def execute_preset( graph_id: str, graph_version: int, preset_id: str, - node_input: Annotated[ - dict[str, Any], fastapi.Body(..., embed=True, default_factory=dict) - ], - user_id: Annotated[str, fastapi.Depends(autogpt_auth_lib.depends.get_user_id)], + node_input: Annotated[dict[str, Any], Body(..., embed=True, default_factory=dict)], + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), ) -> dict[str, Any]: # FIXME: add proper return type - try: - preset = await library_db.get_preset(user_id, preset_id) - if not preset: - raise fastapi.HTTPException(status_code=404, detail="Preset not found") + """ + Execute a preset given graph parameters, returning the execution ID on success. - logger.debug(f"Preset inputs: {preset.inputs}") + Args: + graph_id (str): ID of the graph to execute. + graph_version (int): Version of the graph to execute. + preset_id (str): ID of the preset to execute. + node_input (Dict[Any, Any]): Input data for the node. + user_id (str): ID of the authenticated user. + + Returns: + Dict[str, Any]: A response containing the execution ID. + + Raises: + HTTPException: If the preset is not found or an error occurs while executing the preset. + """ + try: + preset = await db.get_preset(user_id, preset_id) + if not preset: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Preset not found", + ) # Merge input overrides with preset inputs merged_node_input = preset.inputs | node_input @@ -125,6 +237,11 @@ async def execute_preset( logger.debug(f"Execution added: {execution} with input: {merged_node_input}") return {"id": execution.graph_exec_id} + except HTTPException: + raise except Exception as e: - msg = str(e).encode().decode("unicode_escape") - raise fastapi.HTTPException(status_code=400, detail=msg) + logger.exception(f"Exception occurred while executing preset: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) diff --git a/autogpt_platform/backend/backend/server/v2/library/routes_test.py b/autogpt_platform/backend/backend/server/v2/library/routes_test.py index 55efe586b4..8bcea24451 100644 --- a/autogpt_platform/backend/backend/server/v2/library/routes_test.py +++ b/autogpt_platform/backend/backend/server/v2/library/routes_test.py @@ -6,6 +6,7 @@ import fastapi.testclient import pytest import pytest_mock +import backend.server.model as server_model import backend.server.v2.library.model as library_model from backend.server.v2.library.routes import router as library_router @@ -31,59 +32,80 @@ app.dependency_overrides[autogpt_auth_lib.depends.get_user_id] = override_get_us def test_get_library_agents_success(mocker: pytest_mock.MockFixture): mocked_value = [ - library_model.LibraryAgent( - id="test-agent-1", - agent_id="test-agent-1", - agent_version=1, - preset_id="preset-1", - updated_at=datetime.datetime(2023, 1, 1, 0, 0, 0), - is_favorite=False, - is_created_by_user=True, - is_latest_version=True, - name="Test Agent 1", - description="Test Description 1", - input_schema={"type": "object", "properties": {}}, - output_schema={"type": "object", "properties": {}}, - ), - library_model.LibraryAgent( - id="test-agent-2", - agent_id="test-agent-2", - agent_version=1, - preset_id="preset-2", - updated_at=datetime.datetime(2023, 1, 1, 0, 0, 0), - is_favorite=False, - is_created_by_user=False, - is_latest_version=True, - name="Test Agent 2", - description="Test Description 2", - input_schema={"type": "object", "properties": {}}, - output_schema={"type": "object", "properties": {}}, + library_model.LibraryAgentResponse( + agents=[ + library_model.LibraryAgent( + id="test-agent-1", + agent_id="test-agent-1", + agent_version=1, + name="Test Agent 1", + description="Test Description 1", + image_url=None, + creator_name="Test Creator", + creator_image_url="", + input_schema={"type": "object", "properties": {}}, + status=library_model.LibraryAgentStatus.COMPLETED, + new_output=False, + can_access_graph=True, + is_latest_version=True, + updated_at=datetime.datetime(2023, 1, 1, 0, 0, 0), + ), + library_model.LibraryAgent( + id="test-agent-2", + agent_id="test-agent-2", + agent_version=1, + name="Test Agent 2", + description="Test Description 2", + image_url=None, + creator_name="Test Creator", + creator_image_url="", + input_schema={"type": "object", "properties": {}}, + status=library_model.LibraryAgentStatus.COMPLETED, + new_output=False, + can_access_graph=False, + is_latest_version=True, + updated_at=datetime.datetime(2023, 1, 1, 0, 0, 0), + ), + ], + pagination=server_model.Pagination( + total_items=2, total_pages=1, current_page=1, page_size=50 + ), ), ] mock_db_call = mocker.patch("backend.server.v2.library.db.get_library_agents") mock_db_call.return_value = mocked_value - response = client.get("/agents") + response = client.get("/agents?search_term=test") assert response.status_code == 200 - data = [ - library_model.LibraryAgent.model_validate(agent) for agent in response.json() - ] - assert len(data) == 2 - assert data[0].agent_id == "test-agent-1" - assert data[0].is_created_by_user is True - assert data[1].agent_id == "test-agent-2" - assert data[1].is_created_by_user is False - mock_db_call.assert_called_once_with("test-user-id") + data = library_model.LibraryAgentResponse.model_validate(response.json()) + assert len(data.agents) == 2 + assert data.agents[0].agent_id == "test-agent-1" + assert data.agents[0].can_access_graph is True + assert data.agents[1].agent_id == "test-agent-2" + assert data.agents[1].can_access_graph is False + mock_db_call.assert_called_once_with( + user_id="test-user-id", + search_term="test", + sort_by=library_model.LibraryAgentSort.UPDATED_AT, + page=1, + page_size=15, + ) def test_get_library_agents_error(mocker: pytest_mock.MockFixture): mock_db_call = mocker.patch("backend.server.v2.library.db.get_library_agents") mock_db_call.side_effect = Exception("Test error") - response = client.get("/agents") + response = client.get("/agents?search_term=test") assert response.status_code == 500 - mock_db_call.assert_called_once_with("test-user-id") + mock_db_call.assert_called_once_with( + user_id="test-user-id", + search_term="test", + sort_by=library_model.LibraryAgentSort.UPDATED_AT, + page=1, + page_size=15, + ) @pytest.mark.skip(reason="Mocker Not implemented") diff --git a/autogpt_platform/backend/backend/server/v2/store/db.py b/autogpt_platform/backend/backend/server/v2/store/db.py index 3381c9f44e..eaabbaf25f 100644 --- a/autogpt_platform/backend/backend/server/v2/store/db.py +++ b/autogpt_platform/backend/backend/server/v2/store/db.py @@ -601,7 +601,7 @@ async def get_user_profile( avatar_url=profile.avatarUrl, ) except Exception as e: - logger.error("Error getting user profile: %s", e) + logger.error(f"Error getting user profile: {e}") raise backend.server.v2.store.exceptions.DatabaseError( "Failed to get user profile" ) from e @@ -620,7 +620,7 @@ async def update_profile( Raises: DatabaseError: If there's an issue updating or creating the profile """ - logger.info("Updating profile for user %s with data: %s", user_id, profile) + logger.info(f"Updating profile for user {user_id} with data: {profile}") try: # Sanitize username to allow only letters, numbers, and hyphens username = "".join( @@ -639,15 +639,13 @@ async def update_profile( # Verify that the user is authorized to update this profile if existing_profile.userId != user_id: logger.error( - "Unauthorized update attempt for profile %s by user %s", - existing_profile.userId, - user_id, + f"Unauthorized update attempt for profile {existing_profile.id} by user {user_id}" ) raise backend.server.v2.store.exceptions.DatabaseError( f"Unauthorized update attempt for profile {existing_profile.id} by user {user_id}" ) - logger.debug("Updating existing profile for user %s", user_id) + logger.debug(f"Updating existing profile for user {user_id}") # Prepare update data, only including non-None values update_data = {} if profile.name is not None: @@ -667,7 +665,7 @@ async def update_profile( data=prisma.types.ProfileUpdateInput(**update_data), ) if updated_profile is None: - logger.error("Failed to update profile for user %s", user_id) + logger.error(f"Failed to update profile for user {user_id}") raise backend.server.v2.store.exceptions.DatabaseError( "Failed to update profile" ) @@ -684,7 +682,7 @@ async def update_profile( ) except prisma.errors.PrismaError as e: - logger.error("Database error updating profile: %s", e) + logger.error(f"Database error updating profile: {e}") raise backend.server.v2.store.exceptions.DatabaseError( "Failed to update profile" ) from e diff --git a/autogpt_platform/backend/backend/server/v2/store/image_gen.py b/autogpt_platform/backend/backend/server/v2/store/image_gen.py index a1a22d1592..c75b6c23dc 100644 --- a/autogpt_platform/backend/backend/server/v2/store/image_gen.py +++ b/autogpt_platform/backend/backend/server/v2/store/image_gen.py @@ -5,6 +5,7 @@ from enum import Enum import replicate import replicate.exceptions import requests +from prisma.models import AgentGraph from replicate.helpers import FileOutput from backend.data.graph import Graph @@ -21,7 +22,7 @@ class ImageStyle(str, Enum): DIGITAL_ART = "digital art" -async def generate_agent_image(agent: Graph) -> io.BytesIO: +async def generate_agent_image(agent: Graph | AgentGraph) -> io.BytesIO: """ Generate an image for an agent using Flux model via Replicate API. diff --git a/autogpt_platform/backend/backend/server/v2/store/media.py b/autogpt_platform/backend/backend/server/v2/store/media.py index 3ae9f0f799..91f4da03c1 100644 --- a/autogpt_platform/backend/backend/server/v2/store/media.py +++ b/autogpt_platform/backend/backend/server/v2/store/media.py @@ -6,6 +6,7 @@ import fastapi from google.cloud import storage import backend.server.v2.store.exceptions +from backend.util.exceptions import MissingConfigError from backend.util.settings import Settings logger = logging.getLogger(__name__) @@ -27,34 +28,32 @@ async def check_media_exists(user_id: str, filename: str) -> str | None: Returns: str | None: URL of the blob if it exists, None otherwise """ - try: - settings = Settings() - storage_client = storage.Client() - bucket = storage_client.bucket(settings.config.media_gcs_bucket_name) + settings = Settings() + if not settings.config.media_gcs_bucket_name: + raise MissingConfigError("GCS media bucket is not configured") - # Check images - image_path = f"users/{user_id}/images/{filename}" - image_blob = bucket.blob(image_path) - if image_blob.exists(): - return image_blob.public_url + storage_client = storage.Client() + bucket = storage_client.bucket(settings.config.media_gcs_bucket_name) - # Check videos - video_path = f"users/{user_id}/videos/{filename}" + # Check images + image_path = f"users/{user_id}/images/{filename}" + image_blob = bucket.blob(image_path) + if image_blob.exists(): + return image_blob.public_url - video_blob = bucket.blob(video_path) - if video_blob.exists(): - return video_blob.public_url + # Check videos + video_path = f"users/{user_id}/videos/{filename}" - return None - except Exception as e: - logger.error(f"Error checking if media file exists: {str(e)}") - return None + video_blob = bucket.blob(video_path) + if video_blob.exists(): + return video_blob.public_url + + return None async def upload_media( user_id: str, file: fastapi.UploadFile, use_file_name: bool = False ) -> str: - # Get file content for deeper validation try: content = await file.read(1024) # Read first 1KB for validation diff --git a/autogpt_platform/backend/migrations/20250203133647_add_image_url/migration.sql b/autogpt_platform/backend/migrations/20250203133647_add_image_url/migration.sql new file mode 100644 index 0000000000..33a9bbf1d9 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250203133647_add_image_url/migration.sql @@ -0,0 +1,8 @@ +-- Add imageUrl column +ALTER TABLE "LibraryAgent" +ADD COLUMN "creatorId" TEXT, +ADD COLUMN "imageUrl" TEXT; + +-- Add foreign key constraint for creatorId -> Profile +ALTER TABLE "LibraryAgent" +ADD CONSTRAINT "LibraryAgent_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "Profile"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index c98e9113ae..9c922e811e 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -65,6 +65,7 @@ model AgentGraph { name String? description String? + isActive Boolean @default(true) isTemplate Boolean @default(false) @@ -175,6 +176,8 @@ model LibraryAgent { userId String User User @relation(fields: [userId], references: [id], onDelete: Cascade) + imageUrl String? + agentId String agentVersion Int Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version]) @@ -182,6 +185,9 @@ model LibraryAgent { agentPresetId String? AgentPreset AgentPreset? @relation(fields: [agentPresetId], references: [id]) + creatorId String? + Creator Profile? @relation(fields: [creatorId], references: [id]) + useGraphIsActiveVersion Boolean @default(false) isFavorite Boolean @default(false) @@ -505,6 +511,8 @@ model Profile { isFeatured Boolean @default(false) + LibraryAgents LibraryAgent[] + @@index([username]) @@index([userId]) } diff --git a/autogpt_platform/backend/test/executor/test_manager.py b/autogpt_platform/backend/test/executor/test_manager.py index 40dbc9dfaa..6094f4b66b 100644 --- a/autogpt_platform/backend/test/executor/test_manager.py +++ b/autogpt_platform/backend/test/executor/test_manager.py @@ -470,7 +470,7 @@ async def test_execute_preset_with_clash(server: SpinTestServer): ) # Verify execution - assert result is not None + assert result is not None, "Result must not be None" graph_exec_id = result["id"] # Wait for execution to complete diff --git a/autogpt_platform/frontend/next.config.mjs b/autogpt_platform/frontend/next.config.mjs index 6700943d43..b7f69edfdb 100644 --- a/autogpt_platform/frontend/next.config.mjs +++ b/autogpt_platform/frontend/next.config.mjs @@ -19,6 +19,7 @@ const nextConfig = { typescript: { ignoreBuildErrors: true, }, + transpilePackages: ["geist"], }; export default withSentryConfig(nextConfig, { diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index 6a8db9b512..9403378b21 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -62,6 +62,7 @@ "embla-carousel-react": "^8.5.2", "framer-motion": "^12.0.11", "geist": "^1.3.1", + "lodash.debounce": "^4.0.8", "launchdarkly-react-client-sdk": "^3.6.1", "lucide-react": "^0.474.0", "moment": "^2.30.1", @@ -70,7 +71,8 @@ "react": "^18", "react-day-picker": "^9.5.1", "react-dom": "^18", - "react-hook-form": "^7.54.2", + "react-drag-drop-files": "^2.4.0", + "react-hook-form": "^7.54.0", "react-icons": "^5.4.0", "react-markdown": "^9.0.3", "react-modal": "^3.16.3", @@ -93,7 +95,8 @@ "@storybook/nextjs": "^8.5.3", "@storybook/react": "^8.3.5", "@storybook/test": "^8.3.5", - "@storybook/test-runner": "^0.21.0", + "@storybook/test-runner": "^0.20.1", + "@types/lodash": "^4.17.13", "@types/negotiator": "^0.6.3", "@types/node": "^22.13.0", "@types/react": "^18", diff --git a/autogpt_platform/frontend/src/app/layout.tsx b/autogpt_platform/frontend/src/app/layout.tsx index 6cb18d089d..e9fe284c04 100644 --- a/autogpt_platform/frontend/src/app/layout.tsx +++ b/autogpt_platform/frontend/src/app/layout.tsx @@ -1,21 +1,22 @@ import React from "react"; import type { Metadata } from "next"; import { Inter, Poppins } from "next/font/google"; -import { Providers } from "@/app/providers"; -import { cn } from "@/lib/utils"; -import { Navbar } from "@/components/agptui/Navbar"; - -import "./globals.css"; -import TallyPopupSimple from "@/components/TallyPopup"; import { GoogleAnalytics } from "@next/third-parties/google"; -import { Toaster } from "@/components/ui/toaster"; -import { IconType } from "@/components/ui/icons"; import { GeistSans } from "geist/font/sans"; import { GeistMono } from "geist/font/mono"; import { headers } from "next/headers"; -// Fonts +import { cn } from "@/lib/utils"; +import "./globals.css"; + +import { Navbar } from "@/components/agptui/Navbar"; +import { Toaster } from "@/components/ui/toaster"; +import { IconType } from "@/components/ui/icons"; +import { Providers } from "@/app/providers"; +import TallyPopupSimple from "@/components/TallyPopup"; + const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); + const poppins = Poppins({ subsets: ["latin"], weight: ["400", "500", "600", "700"], @@ -34,14 +35,18 @@ export default async function RootLayout({ }>) { const pathname = headers().get("x-current-path"); const isOnboarding = pathname?.startsWith("/onboarding"); - console.log("pathname:", pathname); return ( - + {/* Run / Schedule views */} - {(selectedView.type == "run" ? ( - selectedView.id ? ( - selectedRun && ( - - ) - ) : ( - selectRun(runID)} + run={selectedRun} agentActions={agentActions} /> ) + ) : selectedView.type == "run" ? ( + selectRun(runID)} + agentActions={agentActions} + /> ) : selectedView.type == "schedule" ? ( selectedSchedule && ( + + {/* Header section containing notifications, search functionality and upload mechanism */} + + + {/* Subheader section containing agent counts and filtering options */} + + + {/* Content section displaying agent list with counter and filtering options */} + + + +
+

+ Prefer the old experience? Click{" "} + + here + {" "} + to go to it. Please do let us know why by clicking the{" "} + {" "} + in the bottom right corner +

+
+ + ); +} diff --git a/autogpt_platform/frontend/src/app/library/state-provider.tsx b/autogpt_platform/frontend/src/app/library/state-provider.tsx new file mode 100644 index 0000000000..70ac649a3f --- /dev/null +++ b/autogpt_platform/frontend/src/app/library/state-provider.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { + createContext, + useState, + ReactNode, + useContext, + Dispatch, + SetStateAction, +} from "react"; +import { LibraryAgent, LibraryAgentSortEnum } from "@/lib/autogpt-server-api"; + +interface LibraryPageContextType { + agents: LibraryAgent[]; + setAgents: Dispatch>; + agentLoading: boolean; + setAgentLoading: Dispatch>; + searchTerm: string | undefined; + setSearchTerm: Dispatch>; + uploadedFile: File | null; + setUploadedFile: Dispatch>; + librarySort: LibraryAgentSortEnum; + setLibrarySort: Dispatch>; +} + +export const LibraryPageContext = createContext( + {} as LibraryPageContextType, +); + +export function LibraryPageStateProvider({ + children, +}: { + children: ReactNode; +}) { + const [agents, setAgents] = useState([]); + const [agentLoading, setAgentLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [uploadedFile, setUploadedFile] = useState(null); + const [librarySort, setLibrarySort] = useState( + LibraryAgentSortEnum.UPDATED_AT, + ); + + return ( + + {children} + + ); +} + +export function useLibraryPageContext(): LibraryPageContextType { + const context = useContext(LibraryPageContext); + if (!context) { + throw new Error("Error in context of Library page"); + } + return context; +} diff --git a/autogpt_platform/frontend/src/app/monitoring/page.tsx b/autogpt_platform/frontend/src/app/monitoring/page.tsx index bfacf44cf4..dd1cf2c7c0 100644 --- a/autogpt_platform/frontend/src/app/monitoring/page.tsx +++ b/autogpt_platform/frontend/src/app/monitoring/page.tsx @@ -43,8 +43,8 @@ const Monitor = () => { ); const fetchAgents = useCallback(() => { - api.listLibraryAgents().then((agents) => { - setFlows(agents); + api.listLibraryAgents().then((response) => { + setFlows(response.agents); }); api.getExecutions().then((executions) => { setExecutions(executions); diff --git a/autogpt_platform/frontend/src/app/profile/(user)/page.tsx b/autogpt_platform/frontend/src/app/profile/(user)/page.tsx index c9c475e032..14874dfa54 100644 --- a/autogpt_platform/frontend/src/app/profile/(user)/page.tsx +++ b/autogpt_platform/frontend/src/app/profile/(user)/page.tsx @@ -5,7 +5,7 @@ import { CreatorDetails } from "@/lib/autogpt-server-api/types"; async function getProfileData(api: BackendAPI) { try { - const profile = await api.getStoreProfile("profile"); + const profile = await api.getStoreProfile(); return { profile, }; diff --git a/autogpt_platform/frontend/src/components/agent-import-form.tsx b/autogpt_platform/frontend/src/components/agent-import-form.tsx index 34808c27ae..2b3cb2e2e8 100644 --- a/autogpt_platform/frontend/src/components/agent-import-form.tsx +++ b/autogpt_platform/frontend/src/components/agent-import-form.tsx @@ -31,36 +31,37 @@ const formSchema = z.object({ importAsTemplate: z.boolean(), }); -function updateBlockIDs(graph: Graph) { +export const updatedBlockIDMap: Record = { // https://github.com/Significant-Gravitas/AutoGPT/issues/8223 - const updatedBlockIDMap: Record = { - "a1b2c3d4-5e6f-7g8h-9i0j-k1l2m3n4o5p6": - "436c3984-57fd-4b85-8e9a-459b356883bd", - "b2g2c3d4-5e6f-7g8h-9i0j-k1l2m3n4o5p6": - "0e50422c-6dee-4145-83d6-3a5a392f65de", - "c3d4e5f6-7g8h-9i0j-1k2l-m3n4o5p6q7r8": - "a0a69be1-4528-491c-a85a-a4ab6873e3f0", - "c3d4e5f6-g7h8-i9j0-k1l2-m3n4o5p6q7r8": - "32a87eab-381e-4dd4-bdb8-4c47151be35a", - "b2c3d4e5-6f7g-8h9i-0j1k-l2m3n4o5p6q7": - "87840993-2053-44b7-8da4-187ad4ee518c", - "h1i2j3k4-5l6m-7n8o-9p0q-r1s2t3u4v5w6": - "d0822ab5-9f8a-44a3-8971-531dd0178b6b", - "d3f4g5h6-1i2j-3k4l-5m6n-7o8p9q0r1s2t": - "df06086a-d5ac-4abb-9996-2ad0acb2eff7", - "h5e7f8g9-1b2c-3d4e-5f6g-7h8i9j0k1l2m": - "f5b0f5d0-1862-4d61-94be-3ad0fa772760", - "a1234567-89ab-cdef-0123-456789abcdef": - "4335878a-394e-4e67-adf2-919877ff49ae", - "f8e7d6c5-b4a3-2c1d-0e9f-8g7h6i5j4k3l": - "f66a3543-28d3-4ab5-8945-9b336371e2ce", - "b29c1b50-5d0e-4d9f-8f9d-1b0e6fcbf0h2": - "716a67b3-6760-42e7-86dc-18645c6e00fc", - "31d1064e-7446-4693-o7d4-65e5ca9110d1": - "cc10ff7b-7753-4ff2-9af6-9399b1a7eddc", - "c6731acb-4105-4zp1-bc9b-03d0036h370g": - "5ebe6768-8e5d-41e3-9134-1c7bd89a8d52", - }; + "a1b2c3d4-5e6f-7g8h-9i0j-k1l2m3n4o5p6": + "436c3984-57fd-4b85-8e9a-459b356883bd", + "b2g2c3d4-5e6f-7g8h-9i0j-k1l2m3n4o5p6": + "0e50422c-6dee-4145-83d6-3a5a392f65de", + "c3d4e5f6-7g8h-9i0j-1k2l-m3n4o5p6q7r8": + "a0a69be1-4528-491c-a85a-a4ab6873e3f0", + "c3d4e5f6-g7h8-i9j0-k1l2-m3n4o5p6q7r8": + "32a87eab-381e-4dd4-bdb8-4c47151be35a", + "b2c3d4e5-6f7g-8h9i-0j1k-l2m3n4o5p6q7": + "87840993-2053-44b7-8da4-187ad4ee518c", + "h1i2j3k4-5l6m-7n8o-9p0q-r1s2t3u4v5w6": + "d0822ab5-9f8a-44a3-8971-531dd0178b6b", + "d3f4g5h6-1i2j-3k4l-5m6n-7o8p9q0r1s2t": + "df06086a-d5ac-4abb-9996-2ad0acb2eff7", + "h5e7f8g9-1b2c-3d4e-5f6g-7h8i9j0k1l2m": + "f5b0f5d0-1862-4d61-94be-3ad0fa772760", + "a1234567-89ab-cdef-0123-456789abcdef": + "4335878a-394e-4e67-adf2-919877ff49ae", + "f8e7d6c5-b4a3-2c1d-0e9f-8g7h6i5j4k3l": + "f66a3543-28d3-4ab5-8945-9b336371e2ce", + "b29c1b50-5d0e-4d9f-8f9d-1b0e6fcbf0h2": + "716a67b3-6760-42e7-86dc-18645c6e00fc", + "31d1064e-7446-4693-o7d4-65e5ca9110d1": + "cc10ff7b-7753-4ff2-9af6-9399b1a7eddc", + "c6731acb-4105-4zp1-bc9b-03d0036h370g": + "5ebe6768-8e5d-41e3-9134-1c7bd89a8d52", +}; + +function updateBlockIDs(graph: Graph) { graph.nodes .filter((node) => node.block_id in updatedBlockIDMap) .forEach((node) => { diff --git a/autogpt_platform/frontend/src/components/agptui/AgentInfo.tsx b/autogpt_platform/frontend/src/components/agptui/AgentInfo.tsx index 718f262916..c039419513 100644 --- a/autogpt_platform/frontend/src/components/agptui/AgentInfo.tsx +++ b/autogpt_platform/frontend/src/components/agptui/AgentInfo.tsx @@ -44,9 +44,11 @@ export const AgentInfo: React.FC = ({ const handleAddToLibrary = async () => { try { - await api.addAgentToLibrary(storeListingVersionId); + const newLibraryAgent = await api.addMarketplaceAgentToLibrary( + storeListingVersionId, + ); console.log("Agent added to library successfully"); - router.push("/monitoring"); + router.push(`/library/agents/${newLibraryAgent.agent_id}`); } catch (error) { console.error("Failed to add agent to library:", error); } diff --git a/autogpt_platform/frontend/src/components/agptui/Button.tsx b/autogpt_platform/frontend/src/components/agptui/Button.tsx index 39f0536da3..9da212d4fd 100644 --- a/autogpt_platform/frontend/src/components/agptui/Button.tsx +++ b/autogpt_platform/frontend/src/components/agptui/Button.tsx @@ -12,13 +12,14 @@ const buttonVariants = cva( destructive: "bg-red-600 text-neutral-50 border border-red-500/50 hover:bg-red-500/90 dark:bg-red-700 dark:text-neutral-50 dark:hover:bg-red-600", accent: "bg-accent text-accent-foreground hover:bg-violet-500", + primary: "bg-neutral-800 text-white hover:bg-black/60", outline: - "border border-black/50 text-[#272727] hover:bg-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700", + "border border-black/50 text-neutral-800 hover:bg-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700", secondary: - "bg-neutral-100 text-[#272727] border border-neutral-200 hover:bg-neutral-100/80 dark:bg-neutral-700 dark:text-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-600", + "bg-neutral-100 text-neutral-800 border border-neutral-200 hover:bg-neutral-100/80 dark:bg-neutral-700 dark:text-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-600", ghost: - "hover:bg-neutral-100 text-[#272727] dark:text-neutral-100 dark:hover:bg-neutral-700", - link: "text-[#272727] underline-offset-4 hover:underline dark:text-neutral-100", + "hover:bg-neutral-100 text-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700", + link: "text-neutral-800 underline-offset-4 hover:underline dark:text-neutral-100", }, size: { default: "h-10 px-4 py-2 rounded-full text-sm", @@ -44,10 +45,12 @@ export interface ButtonProps variant?: | "destructive" | "accent" + | "primary" | "outline" | "secondary" | "ghost" | "link"; + size?: "default" | "sm" | "lg" | "primary" | "icon" | "card"; } diff --git a/autogpt_platform/frontend/src/components/agptui/Navbar.tsx b/autogpt_platform/frontend/src/components/agptui/Navbar.tsx index ced3ec6123..92f7f94f86 100644 --- a/autogpt_platform/frontend/src/components/agptui/Navbar.tsx +++ b/autogpt_platform/frontend/src/components/agptui/Navbar.tsx @@ -33,7 +33,7 @@ interface NavbarProps { async function getProfileData() { const api = new BackendAPI(); - const profile = await Promise.resolve(api.getStoreProfile("navbar")); + const profile = await Promise.resolve(api.getStoreProfile()); return profile; } diff --git a/autogpt_platform/frontend/src/components/agptui/NavbarLink.tsx b/autogpt_platform/frontend/src/components/agptui/NavbarLink.tsx index e06044393c..87523f241d 100644 --- a/autogpt_platform/frontend/src/components/agptui/NavbarLink.tsx +++ b/autogpt_platform/frontend/src/components/agptui/NavbarLink.tsx @@ -1,7 +1,6 @@ "use client"; import Link from "next/link"; import { - IconType, IconShoppingCart, IconBoxes, IconLibrary, @@ -47,7 +46,7 @@ export const NavbarLink = ({ name, href }: NavbarLinkProps) => { className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`} /> )} - {href === "/monitoring" && ( + {href === "/library" && ( diff --git a/autogpt_platform/frontend/src/components/library/library-action-header.tsx b/autogpt_platform/frontend/src/components/library/library-action-header.tsx new file mode 100644 index 0000000000..1e8d895d31 --- /dev/null +++ b/autogpt_platform/frontend/src/components/library/library-action-header.tsx @@ -0,0 +1,34 @@ +// import LibraryNotificationDropdown from "./library-notification-dropdown"; +import LibraryUploadAgentDialog from "./library-upload-agent-dialog"; +import LibrarySearchBar from "./library-search-bar"; + +interface LibraryActionHeaderProps {} + +/** + * LibraryActionHeader component - Renders a header with search, notifications and filters + */ +const LibraryActionHeader: React.FC = ({}) => { + return ( + <> +
+ {/* */} + + +
+ + {/* Mobile and tablet */} +
+
+ {/* */} + +
+ +
+ +
+
+ + ); +}; + +export default LibraryActionHeader; diff --git a/autogpt_platform/frontend/src/components/library/library-action-sub-header.tsx b/autogpt_platform/frontend/src/components/library/library-action-sub-header.tsx new file mode 100644 index 0000000000..b11cd42e64 --- /dev/null +++ b/autogpt_platform/frontend/src/components/library/library-action-sub-header.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useLibraryPageContext } from "@/app/library/state-provider"; +import LibrarySortMenu from "./library-sort-menu"; + +export default function LibraryActionSubHeader(): React.ReactNode { + const { agents } = useLibraryPageContext(); + + return ( +
+
+ + My agents + + + {agents.length} agents + +
+ +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/library/library-agent-card.tsx b/autogpt_platform/frontend/src/components/library/library-agent-card.tsx new file mode 100644 index 0000000000..c31dd63ca4 --- /dev/null +++ b/autogpt_platform/frontend/src/components/library/library-agent-card.tsx @@ -0,0 +1,90 @@ +import Link from "next/link"; +import Image from "next/image"; +import { LibraryAgent } from "@/lib/autogpt-server-api"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; + +export default function LibraryAgentCard({ + agent: { + id, + name, + description, + agent_id, + can_access_graph, + creator_image_url, + image_url, + }, +}: { + agent: LibraryAgent; +}): React.ReactNode { + return ( + +
+
+ {!image_url ? ( +
+ ) : ( + {`${name} + )} +
+ + + {name.charAt(0)} + +
+
+ +
+

+ {name} +

+ +

+ {description} +

+ +
+ + See runs + + + {can_access_graph && ( + + Open in builder + + )} +
+
+
+ + ); +} diff --git a/autogpt_platform/frontend/src/components/library/library-agent-list.tsx b/autogpt_platform/frontend/src/components/library/library-agent-list.tsx new file mode 100644 index 0000000000..9a6a302a6c --- /dev/null +++ b/autogpt_platform/frontend/src/components/library/library-agent-list.tsx @@ -0,0 +1,96 @@ +"use client"; +import { useEffect, useState, useCallback } from "react"; + +import { useBackendAPI } from "@/lib/autogpt-server-api/context"; + +import { useLibraryPageContext } from "@/app/library/state-provider"; +import { useScrollThreshold } from "@/hooks/useScrollThreshold"; +import LibraryAgentCard from "./library-agent-card"; + +/** + * Displays a grid of library agents with infinite scroll functionality. + */ +export default function LibraryAgentList(): React.ReactNode { + const [currentPage, setCurrentPage] = useState(1); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + + const api = useBackendAPI(); + const { agents, setAgents, setAgentLoading, agentLoading } = + useLibraryPageContext(); + + const fetchAgents = useCallback( + async (page: number) => { + try { + const response = await api.listLibraryAgents( + page === 1 ? {} : { page: page }, + ); + if (page > 1) { + setAgents((prevAgent) => [...prevAgent, ...response.agents]); + } else { + setAgents(response.agents); + } + console.log(response); + setHasMore( + response.pagination.current_page * response.pagination.page_size < + response.pagination.total_items, + ); + } finally { + setAgentLoading(false); + setLoadingMore(false); + } + }, + [api, setAgents, setAgentLoading], + ); + + useEffect(() => { + fetchAgents(1); + }, [fetchAgents]); + + const handleInfiniteScroll = useCallback( + (scrollY: number) => { + if (!hasMore || loadingMore) return; + + const { scrollHeight, clientHeight } = document.documentElement; + const SCROLL_THRESHOLD = 20; + const FETCH_DELAY = 1000; + + if (scrollY + clientHeight >= scrollHeight - SCROLL_THRESHOLD) { + setLoadingMore(true); + const nextPage = currentPage + 1; + setCurrentPage(nextPage); + setTimeout(() => fetchAgents(nextPage), FETCH_DELAY); + } + }, + [currentPage, hasMore, loadingMore, fetchAgents], + ); + + useScrollThreshold(handleInfiniteScroll, 50); + + const LoadingSpinner = () => ( +
+ ); + + return ( +
+ {agentLoading ? ( +
+ +
+ ) : ( + <> +
+ {agents.map((agent) => ( + + ))} +
+ {loadingMore && hasMore && ( +
+ +
+ )} + + )} +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/library/library-notification-card.tsx b/autogpt_platform/frontend/src/components/library/library-notification-card.tsx new file mode 100644 index 0000000000..3adc1dd6a7 --- /dev/null +++ b/autogpt_platform/frontend/src/components/library/library-notification-card.tsx @@ -0,0 +1,175 @@ +import Image from "next/image"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { + CirclePlayIcon, + ClipboardCopy, + ImageIcon, + PlayCircle, + Share2, + X, +} from "lucide-react"; + +export interface NotificationCardData { + type: "text" | "image" | "video" | "audio"; + title: string; + id: string; + content?: string; + mediaUrl?: string; +} + +interface NotificationCardProps { + notification: NotificationCardData; + onClose: () => void; +} + +const NotificationCard = ({ + notification: { id, type, title, content, mediaUrl }, + onClose, +}: NotificationCardProps) => { + const barHeights = Array.from({ length: 60 }, () => + Math.floor(Math.random() * (34 - 20 + 1) + 20), + ); + + const handleClose = (e: React.MouseEvent) => { + e.preventDefault(); + onClose(); + }; + + return ( +
+
+ {/* count */} +
+

+ 1/4 +

+

+ Success +

+
+ + {/* cross icon */} + +
+ +
+

+ New Output Ready! +

+

+ {title} +

+ {type === "text" && } +
+ +
+ {type === "text" && ( + // Maybe in future we give markdown support +
+ {content} +
+ )} + + {type === "image" && + (mediaUrl ? ( +
+ {title} +
+ ) : ( +
+ +
+ ))} + + {type === "video" && ( +
+ {mediaUrl ? ( +
+ )} + + {type === "audio" && ( +
+ +
+ {/*
+ )} +
+ +
+
+ + +
+ +
+
+ ); +}; + +export default NotificationCard; diff --git a/autogpt_platform/frontend/src/components/library/library-notification-dropdown.tsx b/autogpt_platform/frontend/src/components/library/library-notification-dropdown.tsx new file mode 100644 index 0000000000..8bb131ceb4 --- /dev/null +++ b/autogpt_platform/frontend/src/components/library/library-notification-dropdown.tsx @@ -0,0 +1,132 @@ +"use client"; +import React, { useState, useEffect, useMemo } from "react"; + +import { motion, useAnimationControls } from "framer-motion"; +import { BellIcon, X } from "lucide-react"; +import { Button } from "@/components/agptui/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import LibraryNotificationCard, { + NotificationCardData, +} from "./library-notification-card"; + +export default function LibraryNotificationDropdown(): React.ReactNode { + const controls = useAnimationControls(); + const [open, setOpen] = useState(false); + const [notifications, setNotifications] = useState< + NotificationCardData[] | null + >(null); + + const initialNotificationData = useMemo( + () => + [ + { + type: "audio", + title: "Audio Processing Complete", + id: "4", + }, + { + type: "text", + title: "LinkedIn Post Generator: YouTube to Professional Content", + id: "1", + content: + "As artificial intelligence (AI) continues to evolve, it's increasingly clear that AI isn't just a trend—it's reshaping the way we work, innovate, and solve complex problems. However, for many professionals, the question remains: How can I leverage AI to drive meaningful results in my own field? In this article, we'll explore how AI can empower businesses and individuals alike to be more efficient, make better decisions, and unlock new opportunities. Whether you're in tech, finance, healthcare, or any other industry, understanding the potential of AI can set you apart.", + }, + { + type: "image", + title: "New Image Upload", + id: "2", + }, + { + type: "video", + title: "Video Processing Complete", + id: "3", + }, + ] as NotificationCardData[], + [], + ); + + useEffect(() => { + if (initialNotificationData) { + setNotifications(initialNotificationData); + } + }, [initialNotificationData]); + + const handleHoverStart = () => { + controls.start({ + rotate: [0, -10, 10, -10, 10, 0], + transition: { duration: 0.5 }, + }); + }; + + return ( + + + + + + + Agent run updates + + +
+ {notifications && notifications.length ? ( + notifications.map((notification) => ( + + + setNotifications((prev) => { + if (!prev) return null; + return prev.filter((n) => n.id !== notification.id); + }) + } + /> + + )) + ) : ( +
+ No notifications present +
+ )} +
+
+
+ ); +} diff --git a/autogpt_platform/frontend/src/components/library/library-search-bar.tsx b/autogpt_platform/frontend/src/components/library/library-search-bar.tsx new file mode 100644 index 0000000000..39ed884f3f --- /dev/null +++ b/autogpt_platform/frontend/src/components/library/library-search-bar.tsx @@ -0,0 +1,101 @@ +"use client"; +import { useRef, useState } from "react"; +import debounce from "lodash/debounce"; +import { Input } from "@/components/ui/input"; +import { Search, X } from "lucide-react"; +import { useBackendAPI } from "@/lib/autogpt-server-api/context"; +import { useLibraryPageContext } from "@/app/library/state-provider"; +import { motion, AnimatePresence } from "framer-motion"; + +export default function LibrarySearchBar(): React.ReactNode { + const inputRef = useRef(null); + const [isFocused, setIsFocused] = useState(false); + const api = useBackendAPI(); + const { setAgentLoading, setAgents, librarySort, setSearchTerm } = + useLibraryPageContext(); + + const debouncedSearch = debounce(async (value: string) => { + try { + setAgentLoading(true); + setSearchTerm(value); + await new Promise((resolve) => setTimeout(resolve, 1000)); + const response = await api.listLibraryAgents({ + search_term: value, + sort_by: librarySort, + page: 1, + }); + setAgents(response.agents); + setAgentLoading(false); + } catch (error) { + console.error("Search failed:", error); + } + }, 300); + const handleSearchInput = (e: React.ChangeEvent) => { + const searchTerm = e.target.value; + debouncedSearch(searchTerm); + }; + + return ( +
inputRef.current?.focus()} + className="relative z-[21] mx-auto flex h-[50px] w-full max-w-[500px] flex-1 cursor-pointer items-center rounded-[45px] bg-[#EDEDED] px-[24px] py-[10px]" + > +
+ + {!isFocused ? ( + + + + ) : ( + + { + if (inputRef.current) { + debouncedSearch(""); + inputRef.current.value = ""; + inputRef.current.blur(); + e.preventDefault(); + } + setIsFocused(false); + }} + /> + + )} + +
+ + setIsFocused(true)} + onBlur={() => !inputRef.current?.value && setIsFocused(false)} + onChange={handleSearchInput} + className="border-none font-sans text-[16px] font-normal leading-7 shadow-none focus:shadow-none" + type="text" + placeholder="Search agents" + /> +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/library/library-sort-menu.tsx b/autogpt_platform/frontend/src/components/library/library-sort-menu.tsx new file mode 100644 index 0000000000..e13ef989e4 --- /dev/null +++ b/autogpt_platform/frontend/src/components/library/library-sort-menu.tsx @@ -0,0 +1,52 @@ +import { useBackendAPI } from "@/lib/autogpt-server-api/context"; +import { LibraryAgentSortEnum } from "@/lib/autogpt-server-api/types"; +import { useLibraryPageContext } from "@/app/library/state-provider"; +import { ArrowDownNarrowWideIcon } from "lucide-react"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export default function LibrarySortMenu(): React.ReactNode { + const api = useBackendAPI(); + const { setAgentLoading, setAgents, setLibrarySort, searchTerm } = + useLibraryPageContext(); + const handleSortChange = async (value: LibraryAgentSortEnum) => { + setLibrarySort(value); + setAgentLoading(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + let response = await api.listLibraryAgents({ + search_term: searchTerm, + sort_by: value, + page: 1, + }); + setAgents(response.agents); + setAgentLoading(false); + }; + + return ( +
+ sort by + +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/library/library-upload-agent-dialog.tsx b/autogpt_platform/frontend/src/components/library/library-upload-agent-dialog.tsx new file mode 100644 index 0000000000..ceae9dc5e0 --- /dev/null +++ b/autogpt_platform/frontend/src/components/library/library-upload-agent-dialog.tsx @@ -0,0 +1,277 @@ +"use client"; +import { useState } from "react"; +import { Upload, X } from "lucide-react"; +import { removeCredentials } from "@/lib/utils"; +import { Button } from "@/components/agptui/Button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { FileUploader } from "react-drag-drop-files"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Graph, GraphCreatable } from "@/lib/autogpt-server-api"; +import { updatedBlockIDMap } from "@/components/agent-import-form"; +import { useBackendAPI } from "@/lib/autogpt-server-api/context"; +import { useToast } from "@/components/ui/use-toast"; + +const fileTypes = ["JSON"]; + +const fileSchema = z.custom((val) => val instanceof File, { + message: "Must be a File object", +}); + +const formSchema = z.object({ + agentFile: fileSchema, + agentName: z.string().min(1, "Agent name is required"), + agentDescription: z.string(), +}); + +function updateBlockIDs(graph: Graph) { + graph.nodes + .filter((node) => node.block_id in updatedBlockIDMap) + .forEach((node) => { + node.block_id = updatedBlockIDMap[node.block_id]; + }); + return graph; +} + +export default function LibraryUploadAgentDialog(): React.ReactNode { + const [isDroped, setisDroped] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const api = useBackendAPI(); + const { toast } = useToast(); + const [agentObject, setAgentObject] = useState(null); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + agentName: "", + agentDescription: "", + }, + }); + + const onSubmit = async (values: z.infer) => { + if (!agentObject) { + form.setError("root", { message: "No Agent object to save" }); + return; + } + + setIsLoading(true); + + const payload: GraphCreatable = { + ...agentObject, + name: values.agentName, + description: values.agentDescription, + is_active: true, + }; + + try { + const response = await api.createGraph(payload); + setIsOpen(false); + toast({ + title: "Success", + description: "Agent uploaded successfully", + variant: "default", + }); + const qID = "flowID"; + window.location.href = `/build?${qID}=${response.id}`; + } catch (error) { + form.setError("root", { + message: `Could not create agent: ${error}`, + }); + } finally { + setIsLoading(false); + } + }; + + const handleChange = (file: File) => { + setTimeout(() => { + setisDroped(false); + }, 2000); + + form.setValue("agentFile", file); + const reader = new FileReader(); + reader.onload = (event) => { + try { + const obj = JSON.parse(event.target?.result as string); + if ( + !["name", "description", "nodes", "links"].every( + (key) => key in obj && obj[key] != null, + ) + ) { + throw new Error( + "Invalid agent object in file: " + JSON.stringify(obj, null, 2), + ); + } + const agent = obj as Graph; + removeCredentials(agent); + updateBlockIDs(agent); + setAgentObject(agent); + if (!form.getValues("agentName")) { + form.setValue("agentName", agent.name); + } + if (!form.getValues("agentDescription")) { + form.setValue("agentDescription", agent.description); + } + } catch (error) { + console.error("Error loading agent file:", error); + } + }; + reader.readAsText(file); + setisDroped(false); + }; + + return ( + + + + + + + Upload Agent + + +
+ + ( + + Agent name + + + + + + )} + /> + + ( + + Description + +