feat(platform): Add basic library functionality (#9043)

Add functionality to allow users to add agents to their library from the
store page.
This commit is contained in:
Swifty 2024-12-18 14:01:48 +01:00 committed by GitHub
parent 6ec2bacb72
commit 9d93704264
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 702 additions and 20 deletions

View File

@ -16,6 +16,7 @@ import backend.data.db
import backend.data.graph
import backend.data.user
import backend.server.routers.v1
import backend.server.v2.library.routes
import backend.server.v2.store.routes
import backend.util.service
import backend.util.settings
@ -89,6 +90,9 @@ app.include_router(backend.server.routers.v1.v1_router, tags=["v1"], prefix="/ap
app.include_router(
backend.server.v2.store.routes.router, tags=["v2"], prefix="/api/store"
)
app.include_router(
backend.server.v2.library.routes.router, tags=["v2"], prefix="/api/library"
)
@app.get(path="/health", tags=["health"], dependencies=[])

View File

@ -0,0 +1,165 @@
import logging
from typing import List
import prisma.errors
import prisma.models
import prisma.types
import backend.data.graph
import backend.data.includes
import backend.server.v2.library.model
import backend.server.v2.store.exceptions
logger = logging.getLogger(__name__)
async def get_library_agents(
user_id: str,
) -> List[backend.server.v2.library.model.LibraryAgent]:
"""
Returns all agents (AgentGraph) that belong to the user and all agents in their library (UserAgent table)
"""
logger.debug(f"Getting library agents for user {user_id}")
try:
# Get agents created by user with nodes and links
user_created = await prisma.models.AgentGraph.prisma().find_many(
where=prisma.types.AgentGraphWhereInput(userId=user_id, isActive=True),
include=backend.data.includes.AGENT_GRAPH_INCLUDE,
)
# Get agents in user's library with nodes and links
library_agents = await prisma.models.UserAgent.prisma().find_many(
where=prisma.types.UserAgentWhereInput(
userId=user_id, isDeleted=False, isArchived=False
),
include={
"Agent": {
"include": {
"AgentNodes": {
"include": {
"Input": True,
"Output": True,
"Webhook": True,
"AgentBlock": True,
}
}
}
}
},
)
# Convert to Graph models first
graphs = []
# Add user created agents
for agent in user_created:
try:
graphs.append(backend.data.graph.GraphModel.from_db(agent))
except Exception as e:
logger.error(f"Error processing user created agent {agent.id}: {e}")
continue
# Add library agents
for agent in library_agents:
if agent.Agent:
try:
graphs.append(backend.data.graph.GraphModel.from_db(agent.Agent))
except Exception as e:
logger.error(f"Error processing library agent {agent.agentId}: {e}")
continue
# Convert Graph models to LibraryAgent models
result = []
for graph in graphs:
result.append(
backend.server.v2.library.model.LibraryAgent(
id=graph.id,
version=graph.version,
is_active=graph.is_active,
name=graph.name,
description=graph.description,
isCreatedByUser=any(a.id == graph.id for a in user_created),
input_schema=graph.input_schema,
output_schema=graph.output_schema,
)
)
logger.debug(f"Found {len(result)} library agents")
return result
except prisma.errors.PrismaError as e:
logger.error(f"Database error getting library agents: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch library agents"
) from e
async def add_agent_to_library(store_listing_version_id: str, user_id: str) -> None:
"""
Finds the agent from the store listing version and adds it to the user's library (UserAgent table)
if they don't already have it
"""
logger.debug(
f"Adding agent from store listing version {store_listing_version_id} 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 backend.server.v2.store.exceptions.AgentNotFoundError(
f"Store listing version {store_listing_version_id} not found"
)
agent = store_listing_version.Agent
if agent.userId == user_id:
logger.warning(
f"User {user_id} cannot add their own agent to their library"
)
raise backend.server.v2.store.exceptions.DatabaseError(
"Cannot add own agent to library"
)
# Check if user already has this agent
existing_user_agent = await prisma.models.UserAgent.prisma().find_first(
where={
"userId": user_id,
"agentId": agent.id,
"agentVersion": agent.version,
}
)
if existing_user_agent:
logger.debug(
f"User {user_id} already has agent {agent.id} in their library"
)
return
# Create UserAgent entry
await prisma.models.UserAgent.prisma().create(
data=prisma.types.UserAgentCreateInput(
userId=user_id,
agentId=agent.id,
agentVersion=agent.version,
isCreatedByUser=False,
)
)
logger.debug(f"Added agent {agent.id} to library for user {user_id}")
except backend.server.v2.store.exceptions.AgentNotFoundError:
raise
except prisma.errors.PrismaError as e:
logger.error(f"Database error adding agent to library: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to add agent to library"
) from e

View File

@ -0,0 +1,189 @@
from datetime import datetime
import prisma.errors
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
@pytest.fixture(autouse=True)
async def setup_prisma():
# Don't register client if already registered
try:
Prisma()
except prisma.errors.ClientAlreadyRegisteredError:
pass
yield
@pytest.mark.asyncio
async def test_get_library_agents(mocker):
# Mock data
mock_user_created = [
prisma.models.AgentGraph(
id="agent1",
version=1,
name="Test Agent 1",
description="Test Description 1",
userId="test-user",
isActive=True,
createdAt=datetime.now(),
isTemplate=False,
)
]
mock_library_agents = [
prisma.models.UserAgent(
id="ua1",
userId="test-user",
agentId="agent2",
agentVersion=1,
isCreatedByUser=False,
isDeleted=False,
isArchived=False,
createdAt=datetime.now(),
updatedAt=datetime.now(),
isFavorite=False,
Agent=prisma.models.AgentGraph(
id="agent2",
version=1,
name="Test Agent 2",
description="Test Description 2",
userId="other-user",
isActive=True,
createdAt=datetime.now(),
isTemplate=False,
),
)
]
# Mock prisma calls
mock_agent_graph = mocker.patch("prisma.models.AgentGraph.prisma")
mock_agent_graph.return_value.find_many = mocker.AsyncMock(
return_value=mock_user_created
)
mock_user_agent = mocker.patch("prisma.models.UserAgent.prisma")
mock_user_agent.return_value.find_many = mocker.AsyncMock(
return_value=mock_library_agents
)
# Call function
result = await db.get_library_agents("test-user")
# Verify results
assert len(result) == 2
assert result[0].id == "agent1"
assert result[0].name == "Test Agent 1"
assert result[0].description == "Test Description 1"
assert result[0].isCreatedByUser is True
assert result[1].id == "agent2"
assert result[1].name == "Test Agent 2"
assert result[1].description == "Test Description 2"
assert result[1].isCreatedByUser is False
# Verify mocks called correctly
mock_agent_graph.return_value.find_many.assert_called_once_with(
where=prisma.types.AgentGraphWhereInput(userId="test-user", isActive=True),
include=backend.data.includes.AGENT_GRAPH_INCLUDE,
)
mock_user_agent.return_value.find_many.assert_called_once_with(
where=prisma.types.UserAgentWhereInput(
userId="test-user", isDeleted=False, isArchived=False
),
include={
"Agent": {
"include": {
"AgentNodes": {
"include": {
"Input": True,
"Output": True,
"Webhook": True,
"AgentBlock": True,
}
}
}
}
},
)
@pytest.mark.asyncio
async def test_add_agent_to_library(mocker):
# Mock data
mock_store_listing = prisma.models.StoreListingVersion(
id="version123",
version=1,
createdAt=datetime.now(),
updatedAt=datetime.now(),
agentId="agent1",
agentVersion=1,
slug="test-agent",
name="Test Agent",
subHeading="Test Agent Subheading",
imageUrls=["https://example.com/image.jpg"],
description="Test Description",
categories=["test"],
isFeatured=False,
isDeleted=False,
isAvailable=True,
isApproved=True,
Agent=prisma.models.AgentGraph(
id="agent1",
version=1,
name="Test Agent",
description="Test Description",
userId="creator",
isActive=True,
createdAt=datetime.now(),
isTemplate=False,
),
)
# Mock prisma calls
mock_store_listing_version = mocker.patch(
"prisma.models.StoreListingVersion.prisma"
)
mock_store_listing_version.return_value.find_unique = mocker.AsyncMock(
return_value=mock_store_listing
)
mock_user_agent = mocker.patch("prisma.models.UserAgent.prisma")
mock_user_agent.return_value.create = mocker.AsyncMock()
# Call function
await db.add_agent_to_library("version123", "test-user")
# Verify mocks called correctly
mock_store_listing_version.return_value.find_unique.assert_called_once_with(
where={"id": "version123"}, include={"Agent": True}
)
mock_user_agent.return_value.create.assert_called_once_with(
data=prisma.types.UserAgentCreateInput(
userId="test-user", agentId="agent1", agentVersion=1, isCreatedByUser=False
)
)
@pytest.mark.asyncio
async def test_add_agent_to_library_not_found(mocker):
# Mock prisma calls
mock_store_listing_version = mocker.patch(
"prisma.models.StoreListingVersion.prisma"
)
mock_store_listing_version.return_value.find_unique = mocker.AsyncMock(
return_value=None
)
# Call function and verify exception
with pytest.raises(backend.server.v2.store.exceptions.AgentNotFoundError):
await db.add_agent_to_library("version123", "test-user")
# Verify mock called correctly
mock_store_listing_version.return_value.find_unique.assert_called_once_with(
where={"id": "version123"}, include={"Agent": True}
)

View File

@ -0,0 +1,16 @@
import typing
import pydantic
class LibraryAgent(pydantic.BaseModel):
id: str # Changed from agent_id to match GraphMeta
version: int # Changed from agent_version to match GraphMeta
is_active: bool # Added to match GraphMeta
name: str
description: str
isCreatedByUser: bool
# Made input_schema and output_schema match GraphMeta's type
input_schema: dict[str, typing.Any] # Should be BlockIOObjectSubSchema in frontend
output_schema: dict[str, typing.Any] # Should be BlockIOObjectSubSchema in frontend

View File

@ -0,0 +1,43 @@
import backend.server.v2.library.model
def test_library_agent():
agent = backend.server.v2.library.model.LibraryAgent(
id="test-agent-123",
version=1,
is_active=True,
name="Test Agent",
description="Test description",
isCreatedByUser=False,
input_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
)
assert agent.id == "test-agent-123"
assert agent.version == 1
assert agent.is_active is True
assert agent.name == "Test Agent"
assert agent.description == "Test description"
assert agent.isCreatedByUser is False
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",
version=2,
is_active=True,
name="User Created Agent",
description="An agent created by the user",
isCreatedByUser=True,
input_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
)
assert agent.id == "user-agent-456"
assert agent.version == 2
assert agent.is_active is True
assert agent.name == "User Created Agent"
assert agent.description == "An agent created by the user"
assert agent.isCreatedByUser is True
assert agent.input_schema == {"type": "object", "properties": {}}
assert agent.output_schema == {"type": "object", "properties": {}}

View File

@ -0,0 +1,74 @@
import logging
import typing
import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware
import fastapi
import backend.data.graph
import backend.server.v2.library.db
import backend.server.v2.library.model
logger = logging.getLogger(__name__)
router = fastapi.APIRouter()
@router.get(
"/agents",
tags=["library", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def get_library_agents(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
]
) -> typing.Sequence[backend.server.v2.library.model.LibraryAgent]:
"""
Get all agents in the user's library, including both created and saved agents.
"""
try:
agents = await backend.server.v2.library.db.get_library_agents(user_id)
return agents
except Exception:
logger.exception("Exception occurred whilst getting library agents")
raise fastapi.HTTPException(
status_code=500, detail="Failed to get library agents"
)
@router.post(
"/agents/{store_listing_version_id}",
tags=["library", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
status_code=201,
)
async def add_agent_to_library(
store_listing_version_id: str,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> fastapi.Response:
"""
Add an agent from the store 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
Returns:
fastapi.Response: 201 status code on success
Raises:
HTTPException: If there is an error adding the agent to the library
"""
try:
await backend.server.v2.library.db.add_agent_to_library(
store_listing_version_id=store_listing_version_id, user_id=user_id
)
return fastapi.Response(status_code=201)
except Exception:
logger.exception("Exception occurred whilst adding agent to library")
raise fastapi.HTTPException(
status_code=500, detail="Failed to add agent to library"
)

View File

@ -0,0 +1,103 @@
import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware
import fastapi
import fastapi.testclient
import pytest_mock
import backend.server.v2.library.db
import backend.server.v2.library.model
import backend.server.v2.library.routes
app = fastapi.FastAPI()
app.include_router(backend.server.v2.library.routes.router)
client = fastapi.testclient.TestClient(app)
def override_auth_middleware():
"""Override auth middleware for testing"""
return {"sub": "test-user-id"}
def override_get_user_id():
"""Override get_user_id for testing"""
return "test-user-id"
app.dependency_overrides[autogpt_libs.auth.middleware.auth_middleware] = (
override_auth_middleware
)
app.dependency_overrides[autogpt_libs.auth.depends.get_user_id] = override_get_user_id
def test_get_library_agents_success(mocker: pytest_mock.MockFixture):
mocked_value = [
backend.server.v2.library.model.LibraryAgent(
id="test-agent-1",
version=1,
is_active=True,
name="Test Agent 1",
description="Test Description 1",
isCreatedByUser=True,
input_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
),
backend.server.v2.library.model.LibraryAgent(
id="test-agent-2",
version=1,
is_active=True,
name="Test Agent 2",
description="Test Description 2",
isCreatedByUser=False,
input_schema={"type": "object", "properties": {}},
output_schema={"type": "object", "properties": {}},
),
]
mock_db_call = mocker.patch("backend.server.v2.library.db.get_library_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents")
assert response.status_code == 200
data = [
backend.server.v2.library.model.LibraryAgent.model_validate(agent)
for agent in response.json()
]
assert len(data) == 2
assert data[0].id == "test-agent-1"
assert data[0].isCreatedByUser is True
assert data[1].id == "test-agent-2"
assert data[1].isCreatedByUser is False
mock_db_call.assert_called_once_with("test-user-id")
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")
assert response.status_code == 500
mock_db_call.assert_called_once_with("test-user-id")
def test_add_agent_to_library_success(mocker: pytest_mock.MockFixture):
mock_db_call = mocker.patch("backend.server.v2.library.db.add_agent_to_library")
mock_db_call.return_value = None
response = client.post("/agents/test-version-id")
assert response.status_code == 201
mock_db_call.assert_called_once_with(
store_listing_version_id="test-version-id", user_id="test-user-id"
)
def test_add_agent_to_library_error(mocker: pytest_mock.MockFixture):
mock_db_call = mocker.patch("backend.server.v2.library.db.add_agent_to_library")
mock_db_call.side_effect = Exception("Test error")
response = client.post("/agents/test-version-id")
assert response.status_code == 500
assert response.json()["detail"] == "Failed to add agent to library"
mock_db_call.assert_called_once_with(
store_listing_version_id="test-version-id", user_id="test-user-id"
)

View File

@ -0,0 +1,53 @@
# Store Module
This module implements the backend API for the AutoGPT Store, handling agents, creators, profiles, submissions and media uploads.
## Files
### routes.py
Contains the FastAPI route handlers for the store API endpoints:
- Profile endpoints for managing user profiles
- Agent endpoints for browsing and retrieving store agents
- Creator endpoints for browsing and retrieving creator details
- Store submission endpoints for submitting agents to the store
- Media upload endpoints for submission images/videos
### model.py
Contains Pydantic models for request/response validation and serialization:
- Pagination model for paginated responses
- Models for agents, creators, profiles, submissions
- Request/response models for all API endpoints
### db.py
Contains database access functions using Prisma ORM:
- Functions to query and manipulate store data
- Handles database operations for all API endpoints
- Implements business logic and data validation
### media.py
Handles media file uploads to Google Cloud Storage:
- Validates file types and sizes
- Processes image and video uploads
- Stores files in GCS buckets
- Returns public URLs for uploaded media
## Key Features
- Paginated listings of store agents and creators
- Search and filtering of agents and creators
- Agent submission workflow
- Media file upload handling
- Profile management
- Reviews and ratings
## Authentication
Most endpoints require authentication via the AutoGPT auth middleware. Public endpoints are marked with the "public" tag.
## Error Handling
All database and storage operations include proper error handling and logging. Errors are mapped to appropriate HTTP status codes.

View File

@ -75,13 +75,10 @@ async def upload_media(user_id: str, file: fastapi.UploadFile) -> str:
settings = Settings()
# Check required settings first before doing any file processing
if (
not settings.config.media_gcs_bucket_name
or not settings.config.google_application_credentials
):
logger.error("Missing required GCS settings")
if not settings.config.media_gcs_bucket_name:
logger.error("Missing GCS bucket name setting")
raise backend.server.v2.store.exceptions.StorageConfigError(
"Missing storage configuration"
"Missing storage bucket configuration"
)
try:

View File

@ -434,6 +434,8 @@ async def upload_submission_media(
user_id=user_id, file=file
)
return media_url
except Exception:
except Exception as e:
logger.exception("Exception occurred whilst uploading submission media")
raise
raise fastapi.HTTPException(
status_code=500, detail=f"Failed to upload media file: {str(e)}"
)

View File

@ -153,11 +153,6 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
description="The name of the Google Cloud Storage bucket for media files",
)
google_application_credentials: str = Field(
default="",
description="The path to the Google Cloud credentials JSON file",
)
@field_validator("platform_base_url", "frontend_base_url")
@classmethod
def validate_platform_base_url(cls, v: str, info: ValidationInfo) -> str:

View File

@ -37,7 +37,7 @@ const Monitor = () => {
);
const fetchAgents = useCallback(() => {
api.listGraphs().then((agent) => {
api.listLibraryAgents().then((agent) => {
setFlows(agent);
});
api.getExecutions().then((executions) => {

View File

@ -65,6 +65,7 @@ export default async function Page({
categories={agent.categories}
lastUpdated={agent.updated_at}
version={agent.versions[agent.versions.length - 1]}
storeListingVersionId={agent.store_listing_version_id}
/>
</div>
<AgentImages images={agent.agent_image} />

View File

@ -1,10 +1,10 @@
"use client";
import * as React from "react";
import { IconPlay, IconStar, StarRatingIcons } from "@/components/ui/icons";
import Link from "next/link";
import { IconPlay, StarRatingIcons } from "@/components/ui/icons";
import { Separator } from "@/components/ui/separator";
import BackendAPI from "@/lib/autogpt-server-api";
import { useRouter } from "next/navigation";
interface AgentInfoProps {
name: string;
creator: string;
@ -15,6 +15,7 @@ interface AgentInfoProps {
categories: string[];
lastUpdated: string;
version: string;
storeListingVersionId: string;
}
export const AgentInfo: React.FC<AgentInfoProps> = ({
@ -27,7 +28,22 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
categories,
lastUpdated,
version,
storeListingVersionId,
}) => {
const router = useRouter();
const api = React.useMemo(() => new BackendAPI(), []);
const handleAddToLibrary = async () => {
try {
await api.addAgentToLibrary(storeListingVersionId);
console.log("Agent added to library successfully");
router.push("/monitoring");
} catch (error) {
console.error("Failed to add agent to library:", error);
}
};
return (
<div className="w-full max-w-[396px] px-4 sm:px-6 lg:w-[396px] lg:px-0">
{/* Title */}
@ -52,10 +68,13 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
{/* Run Agent Button */}
<div className="mb-4 w-full lg:mb-6">
<button className="inline-flex w-full items-center justify-center gap-2 rounded-[38px] bg-violet-600 px-4 py-3 transition-colors hover:bg-violet-700 sm:w-auto sm:gap-2.5 sm:px-5 sm:py-3.5 lg:px-6 lg:py-4">
<button
onClick={handleAddToLibrary}
className="inline-flex w-full items-center justify-center gap-2 rounded-[38px] bg-violet-600 px-4 py-3 transition-colors hover:bg-violet-700 sm:w-auto sm:gap-2.5 sm:px-5 sm:py-3.5 lg:px-6 lg:py-4"
>
<IconPlay className="h-5 w-5 text-white sm:h-5 sm:w-5 lg:h-6 lg:w-6" />
<span className="font-poppins text-base font-medium text-neutral-50 sm:text-lg">
Run agent
Add To Library
</span>
</button>
</div>

View File

@ -338,7 +338,16 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
className="w-full appearance-none rounded-[55px] border border-slate-200 py-2.5 pl-4 pr-5 font-['Geist'] text-base font-normal leading-normal text-slate-500 dark:border-slate-700 dark:bg-gray-700 dark:text-slate-300"
>
<option value="">Select a category for your agent</option>
<option value="SEO">SEO</option>
<option value="productivity">Productivity</option>
<option value="writing">Writing & Content</option>
<option value="development">Development</option>
<option value="data">Data & Analytics</option>
<option value="marketing">Marketing & SEO</option>
<option value="research">Research & Learning</option>
<option value="creative">Creative & Design</option>
<option value="business">Business & Finance</option>
<option value="personal">Personal Assistant</option>
<option value="other">Other</option>
{/* Add more options here */}
</select>
</div>

View File

@ -333,6 +333,18 @@ export default class BackendAPI {
return this._get("/store/myagents", params);
}
/////////////////////////////////////////
/////////// V2 LIBRARY API //////////////
/////////////////////////////////////////
async listLibraryAgents(): Promise<GraphMeta[]> {
return this._get("/library/agents");
}
async addAgentToLibrary(storeListingVersionId: string): Promise<void> {
await this._request("POST", `/library/agents/${storeListingVersionId}`);
}
///////////////////////////////////////////
/////////// INTERNAL FUNCTIONS ////////////
//////////////////////////////??///////////