feat(platform): Agent Store V2 (#8874)

# 🌎 Overview

AutoGPT Store Version 2 expands on the Pre-Store by enhancing agent
discovery, providing richer content presentation, and introducing new
user engagement features. The focus is on creating a visually appealing
and interactive marketplace that allows users to explore and evaluate
agents through images, videos, and detailed descriptions.

### Vision

To create a visually compelling and interactive open-source marketplace
for autonomous AI agents, where users can easily discover, evaluate, and
interact with agents through media-rich listings, ratings, and version
history.

### Objectives

📊 Incorporate visuals (icons, images, videos) into agent listings.
 Introduce a rating system and agent run count.
🔄 Provide version history and update logs from creators.
🔍 Improve user experience with advanced search and filtering features.

### Changes 🏗️

<!-- Concisely describe all of the changes made in this pull request:
-->

### Checklist 📋

#### For code changes:
- [ ] I have clearly listed my changes in the PR description
- [ ] I have made a test plan
- [ ] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [ ] ...

<details>
  <summary>Example test plan</summary>
  
  - [ ] Create from scratch and execute an agent with at least 3 blocks
- [ ] Import an agent from file upload, and confirm it executes
correctly
  - [ ] Upload agent to marketplace
- [ ] Import an agent from marketplace and confirm it executes correctly
  - [ ] Edit an agent from monitor, and confirm it executes correctly
</details>

#### For configuration changes:
- [ ] `.env.example` is updated or already compatible with my changes
- [ ] `docker-compose.yml` is updated or already compatible with my
changes
- [ ] I have included a list of my configuration changes in the PR
description (under **Changes**)

<details>
  <summary>Examples of configuration changes</summary>

  - Changing ports
  - Adding new services that need to communicate with each other
  - Secrets or environment variable changes
  - New or infrastructure changes such as databases
</details>

---------

Co-authored-by: Bently <tomnoon9@gmail.com>
Co-authored-by: Aarushi <aarushik93@gmail.com>
pull/8686/head^2
Swifty 2024-12-13 17:35:02 +01:00 committed by GitHub
parent 94a312a279
commit 2de5e3dd83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
197 changed files with 20445 additions and 7544 deletions

View File

@ -42,7 +42,7 @@ jobs:
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
browser: [chromium, webkit]
steps:
- name: Checkout repository

4
.gitignore vendored
View File

@ -173,4 +173,6 @@ LICENSE.rtf
autogpt_platform/backend/settings.py
/.auth
/autogpt_platform/frontend/.auth
.test-contents
*.ign.*
.test-contents

View File

@ -35,3 +35,12 @@ def verify_user(payload: dict | None, admin_only: bool) -> User:
raise fastapi.HTTPException(status_code=403, detail="Admin access required")
return User.from_payload(payload)
def get_user_id(payload: dict = fastapi.Depends(auth_middleware)) -> str:
user_id = payload.get("sub")
if not user_id:
raise fastapi.HTTPException(
status_code=401, detail="User ID not found in token"
)
return user_id

View File

@ -6,18 +6,23 @@ ENV PYTHONUNBUFFERED 1
WORKDIR /app
RUN echo 'Acquire::http::Pipeline-Depth 0;\nAcquire::http::No-Cache true;\nAcquire::BrokenProxy true;\n' > /etc/apt/apt.conf.d/99fixbadproxy
RUN apt-get update --allow-releaseinfo-change --fix-missing
# Install build dependencies
RUN apt-get update \
&& apt-get install -y build-essential curl ffmpeg wget libcurl4-gnutls-dev libexpat1-dev libpq5 gettext libz-dev libssl-dev postgresql-client git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
ENV POETRY_VERSION=1.8.3 \
POETRY_HOME="/opt/poetry" \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false \
PATH="$POETRY_HOME/bin:$PATH"
RUN apt-get install -y build-essential
RUN apt-get install -y libpq5
RUN apt-get install -y libz-dev
RUN apt-get install -y libssl-dev
RUN apt-get install -y postgresql-client
ENV POETRY_VERSION=1.8.3
ENV POETRY_HOME=/opt/poetry
ENV POETRY_NO_INTERACTION=1
ENV POETRY_VIRTUALENVS_CREATE=false
ENV PATH=/opt/poetry/bin:$PATH
# Upgrade pip and setuptools to fix security vulnerabilities
RUN pip3 install --upgrade pip setuptools
@ -39,11 +44,11 @@ FROM python:3.11.10-slim-bookworm AS server_dependencies
WORKDIR /app
ENV POETRY_VERSION=1.8.3 \
POETRY_HOME="/opt/poetry" \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false \
PATH="$POETRY_HOME/bin:$PATH"
ENV POETRY_VERSION=1.8.3
ENV POETRY_HOME=/opt/poetry
ENV POETRY_NO_INTERACTION=1
ENV POETRY_VIRTUALENVS_CREATE=false
ENV PATH=/opt/poetry/bin:$PATH
# Upgrade pip and setuptools to fix security vulnerabilities

View File

@ -143,7 +143,7 @@ class PineconeQueryBlock(Block):
top_k=input_data.top_k,
include_values=input_data.include_values,
include_metadata=input_data.include_metadata,
).to_dict()
).to_dict() # type: ignore
combined_text = ""
if results["matches"]:
texts = [

View File

@ -160,7 +160,7 @@ def SchemaField(
exclude=exclude,
json_schema_extra=json_extra,
**kwargs,
)
) # type: ignore
class _BaseCredentials(BaseModel):

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.store.routes
import backend.util.service
import backend.util.settings
@ -84,7 +85,10 @@ def handle_internal_http_error(status_code: int = 500, log_error: bool = True):
app.add_exception_handler(ValueError, handle_internal_http_error(400))
app.add_exception_handler(Exception, handle_internal_http_error(500))
app.include_router(backend.server.routers.v1.v1_router, tags=["v1"])
app.include_router(backend.server.routers.v1.v1_router, tags=["v1"], prefix="/api")
app.include_router(
backend.server.v2.store.routes.router, tags=["v2"], prefix="/api/store"
)
@app.get(path="/health", tags=["health"], dependencies=[])

View File

@ -69,8 +69,7 @@ integration_creds_manager = IntegrationCredentialsManager()
_user_credit_model = get_user_credit_model()
# Define the API routes
v1_router = APIRouter(prefix="/api")
v1_router = APIRouter()
v1_router.include_router(
backend.server.integrations.router.router,
@ -132,7 +131,7 @@ def execute_graph_block(block_id: str, data: BlockInput) -> CompletedBlockOutput
@v1_router.get(path="/credits", dependencies=[Depends(auth_middleware)])
async def get_user_credits(
user_id: Annotated[str, Depends(get_user_id)]
user_id: Annotated[str, Depends(get_user_id)],
) -> dict[str, int]:
# Credits can go negative, so ensure it's at least 0 for user to see.
return {"credits": max(await _user_credit_model.get_or_refill_credit(user_id), 0)}

View File

@ -0,0 +1,761 @@
import logging
import random
from datetime import datetime
import prisma.enums
import prisma.errors
import prisma.models
import prisma.types
import backend.server.v2.store.exceptions
import backend.server.v2.store.model
logger = logging.getLogger(__name__)
async def get_store_agents(
featured: bool = False,
creator: str | None = None,
sorted_by: str | None = None,
search_query: str | None = None,
category: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreAgentsResponse:
logger.debug(
f"Getting store agents. featured={featured}, creator={creator}, sorted_by={sorted_by}, search={search_query}, category={category}, page={page}"
)
sanitized_query = None
# Sanitize and validate search query by escaping special characters
if search_query is not None:
sanitized_query = search_query.strip()
if not sanitized_query or len(sanitized_query) > 100: # Reasonable length limit
raise backend.server.v2.store.exceptions.DatabaseError(
"Invalid search query"
)
# Escape special SQL characters
sanitized_query = (
sanitized_query.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
.replace("[", "\\[")
.replace("]", "\\]")
.replace("'", "\\'")
.replace('"', '\\"')
.replace(";", "\\;")
.replace("--", "\\--")
.replace("/*", "\\/*")
.replace("*/", "\\*/")
)
where_clause = {}
if featured:
where_clause["featured"] = featured
if creator:
where_clause["creator_username"] = creator
if category:
where_clause["categories"] = {"has": category}
if sanitized_query:
where_clause["OR"] = [
{"agent_name": {"contains": sanitized_query, "mode": "insensitive"}},
{"description": {"contains": sanitized_query, "mode": "insensitive"}},
]
order_by = []
if sorted_by == "rating":
order_by.append({"rating": "desc"})
elif sorted_by == "runs":
order_by.append({"runs": "desc"})
elif sorted_by == "name":
order_by.append({"agent_name": "asc"})
try:
agents = await prisma.models.StoreAgent.prisma().find_many(
where=prisma.types.StoreAgentWhereInput(**where_clause),
order=order_by,
skip=(page - 1) * page_size,
take=page_size,
)
total = await prisma.models.StoreAgent.prisma().count(
where=prisma.types.StoreAgentWhereInput(**where_clause)
)
total_pages = (total + page_size - 1) // page_size
store_agents = [
backend.server.v2.store.model.StoreAgent(
slug=agent.slug,
agent_name=agent.agent_name,
agent_image=agent.agent_image[0] if agent.agent_image else "",
creator=agent.creator_username,
creator_avatar=agent.creator_avatar,
sub_heading=agent.sub_heading,
description=agent.description,
runs=agent.runs,
rating=agent.rating,
)
for agent in agents
]
logger.debug(f"Found {len(store_agents)} agents")
return backend.server.v2.store.model.StoreAgentsResponse(
agents=store_agents,
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=total,
total_pages=total_pages,
page_size=page_size,
),
)
except Exception as e:
logger.error(f"Error getting store agents: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch store agents"
) from e
async def get_store_agent_details(
username: str, agent_name: str
) -> backend.server.v2.store.model.StoreAgentDetails:
logger.debug(f"Getting store agent details for {username}/{agent_name}")
try:
agent = await prisma.models.StoreAgent.prisma().find_first(
where={"creator_username": username, "slug": agent_name}
)
if not agent:
logger.warning(f"Agent not found: {username}/{agent_name}")
raise backend.server.v2.store.exceptions.AgentNotFoundError(
f"Agent {username}/{agent_name} not found"
)
logger.debug(f"Found agent details for {username}/{agent_name}")
return backend.server.v2.store.model.StoreAgentDetails(
store_listing_version_id=agent.storeListingVersionId,
slug=agent.slug,
agent_name=agent.agent_name,
agent_video=agent.agent_video or "",
agent_image=agent.agent_image,
creator=agent.creator_username,
creator_avatar=agent.creator_avatar,
sub_heading=agent.sub_heading,
description=agent.description,
categories=agent.categories,
runs=agent.runs,
rating=agent.rating,
versions=agent.versions,
last_updated=agent.updated_at,
)
except backend.server.v2.store.exceptions.AgentNotFoundError:
raise
except Exception as e:
logger.error(f"Error getting store agent details: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch agent details"
) from e
async def get_store_creators(
featured: bool = False,
search_query: str | None = None,
sorted_by: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.CreatorsResponse:
logger.debug(
f"Getting store creators. featured={featured}, search={search_query}, sorted_by={sorted_by}, page={page}"
)
# Build where clause with sanitized inputs
where = {}
# Add search filter if provided, using parameterized queries
if search_query:
# Sanitize and validate search query by escaping special characters
sanitized_query = search_query.strip()
if not sanitized_query or len(sanitized_query) > 100: # Reasonable length limit
raise backend.server.v2.store.exceptions.DatabaseError(
"Invalid search query"
)
# Escape special SQL characters
sanitized_query = (
sanitized_query.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
.replace("[", "\\[")
.replace("]", "\\]")
.replace("'", "\\'")
.replace('"', '\\"')
.replace(";", "\\;")
.replace("--", "\\--")
.replace("/*", "\\/*")
.replace("*/", "\\*/")
)
where["OR"] = [
{"username": {"contains": sanitized_query, "mode": "insensitive"}},
{"name": {"contains": sanitized_query, "mode": "insensitive"}},
{"description": {"contains": sanitized_query, "mode": "insensitive"}},
]
try:
# Validate pagination parameters
if not isinstance(page, int) or page < 1:
raise backend.server.v2.store.exceptions.DatabaseError(
"Invalid page number"
)
if not isinstance(page_size, int) or page_size < 1 or page_size > 100:
raise backend.server.v2.store.exceptions.DatabaseError("Invalid page size")
# Get total count for pagination using sanitized where clause
total = await prisma.models.Creator.prisma().count(
where=prisma.types.CreatorWhereInput(**where)
)
total_pages = (total + page_size - 1) // page_size
# Add pagination with validated parameters
skip = (page - 1) * page_size
take = page_size
# Add sorting with validated sort parameter
order = []
valid_sort_fields = {"agent_rating", "agent_runs", "num_agents"}
if sorted_by in valid_sort_fields:
order.append({sorted_by: "desc"})
else:
order.append({"username": "asc"})
# Execute query with sanitized parameters
creators = await prisma.models.Creator.prisma().find_many(
where=prisma.types.CreatorWhereInput(**where),
skip=skip,
take=take,
order=order,
)
# Convert to response model
creator_models = [
backend.server.v2.store.model.Creator(
username=creator.username,
name=creator.name,
description=creator.description,
avatar_url=creator.avatar_url,
num_agents=creator.num_agents,
agent_rating=creator.agent_rating,
agent_runs=creator.agent_runs,
)
for creator in creators
]
logger.debug(f"Found {len(creator_models)} creators")
return backend.server.v2.store.model.CreatorsResponse(
creators=creator_models,
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=total,
total_pages=total_pages,
page_size=page_size,
),
)
except Exception as e:
logger.error(f"Error getting store creators: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch store creators"
) from e
async def get_store_creator_details(
username: str,
) -> backend.server.v2.store.model.CreatorDetails:
logger.debug(f"Getting store creator details for {username}")
try:
# Query creator details from database
creator = await prisma.models.Creator.prisma().find_unique(
where={"username": username}
)
if not creator:
logger.warning(f"Creator not found: {username}")
raise backend.server.v2.store.exceptions.CreatorNotFoundError(
f"Creator {username} not found"
)
logger.debug(f"Found creator details for {username}")
return backend.server.v2.store.model.CreatorDetails(
name=creator.name,
username=creator.username,
description=creator.description,
links=creator.links,
avatar_url=creator.avatar_url,
agent_rating=creator.agent_rating,
agent_runs=creator.agent_runs,
top_categories=creator.top_categories,
)
except backend.server.v2.store.exceptions.CreatorNotFoundError:
raise
except Exception as e:
logger.error(f"Error getting store creator details: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch creator details"
) from e
async def get_store_submissions(
user_id: str, page: int = 1, page_size: int = 20
) -> backend.server.v2.store.model.StoreSubmissionsResponse:
logger.debug(f"Getting store submissions for user {user_id}, page={page}")
try:
# Calculate pagination values
skip = (page - 1) * page_size
where = prisma.types.StoreSubmissionWhereInput(user_id=user_id)
# Query submissions from database
submissions = await prisma.models.StoreSubmission.prisma().find_many(
where=where, skip=skip, take=page_size, order=[{"date_submitted": "desc"}]
)
# Get total count for pagination
total = await prisma.models.StoreSubmission.prisma().count(where=where)
total_pages = (total + page_size - 1) // page_size
# Convert to response models
submission_models = [
backend.server.v2.store.model.StoreSubmission(
agent_id=sub.agent_id,
agent_version=sub.agent_version,
name=sub.name,
sub_heading=sub.sub_heading,
slug=sub.slug,
description=sub.description,
image_urls=sub.image_urls or [],
date_submitted=sub.date_submitted or datetime.now(),
status=sub.status,
runs=sub.runs or 0,
rating=sub.rating or 0.0,
)
for sub in submissions
]
logger.debug(f"Found {len(submission_models)} submissions")
return backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=submission_models,
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=total,
total_pages=total_pages,
page_size=page_size,
),
)
except Exception as e:
logger.error(f"Error fetching store submissions: {str(e)}")
# Return empty response rather than exposing internal errors
return backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=[],
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=0,
total_pages=0,
page_size=page_size,
),
)
async def delete_store_submission(
user_id: str,
submission_id: str,
) -> bool:
"""
Delete a store listing submission.
Args:
user_id: ID of the authenticated user
submission_id: ID of the submission to be deleted
Returns:
bool: True if the submission was successfully deleted, False otherwise
"""
logger.debug(f"Deleting store submission {submission_id} for user {user_id}")
try:
# Verify the submission belongs to this user
submission = await prisma.models.StoreListing.prisma().find_first(
where={"agentId": submission_id, "owningUserId": user_id}
)
if not submission:
logger.warning(f"Submission not found for user {user_id}: {submission_id}")
raise backend.server.v2.store.exceptions.SubmissionNotFoundError(
f"Submission not found for this user. User ID: {user_id}, Submission ID: {submission_id}"
)
# Delete the submission
await prisma.models.StoreListing.prisma().delete(
where=prisma.types.StoreListingWhereUniqueInput(id=submission.id)
)
logger.debug(
f"Successfully deleted submission {submission_id} for user {user_id}"
)
return True
except Exception as e:
logger.error(f"Error deleting store submission: {str(e)}")
return False
async def create_store_submission(
user_id: str,
agent_id: str,
agent_version: int,
slug: str,
name: str,
video_url: str | None = None,
image_urls: list[str] = [],
description: str = "",
sub_heading: str = "",
categories: list[str] = [],
) -> backend.server.v2.store.model.StoreSubmission:
"""
Create a new store listing submission.
Args:
user_id: ID of the authenticated user submitting the listing
agent_id: ID of the agent being submitted
agent_version: Version of the agent being submitted
slug: URL slug for the listing
name: Name of the agent
video_url: Optional URL to video demo
image_urls: List of image URLs for the listing
description: Description of the agent
categories: List of categories for the agent
Returns:
StoreSubmission: The created store submission
"""
logger.debug(
f"Creating store submission for user {user_id}, agent {agent_id} v{agent_version}"
)
try:
# First verify the agent belongs to this user
agent = await prisma.models.AgentGraph.prisma().find_first(
where=prisma.types.AgentGraphWhereInput(
id=agent_id, version=agent_version, userId=user_id
)
)
if not agent:
logger.warning(
f"Agent not found for user {user_id}: {agent_id} v{agent_version}"
)
raise backend.server.v2.store.exceptions.AgentNotFoundError(
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
)
listing = await prisma.models.StoreListing.prisma().find_first(
where=prisma.types.StoreListingWhereInput(
agentId=agent_id, owningUserId=user_id
)
)
if listing is not None:
logger.warning(f"Listing already exists for agent {agent_id}")
raise backend.server.v2.store.exceptions.ListingExistsError(
"Listing already exists for this agent"
)
# Create the store listing
listing = await prisma.models.StoreListing.prisma().create(
data={
"agentId": agent_id,
"agentVersion": agent_version,
"owningUserId": user_id,
"createdAt": datetime.now(),
"StoreListingVersions": {
"create": {
"agentId": agent_id,
"agentVersion": agent_version,
"slug": slug,
"name": name,
"videoUrl": video_url,
"imageUrls": image_urls,
"description": description,
"categories": categories,
"subHeading": sub_heading,
}
},
}
)
logger.debug(f"Created store listing for agent {agent_id}")
# Return submission details
return backend.server.v2.store.model.StoreSubmission(
agent_id=agent_id,
agent_version=agent_version,
name=name,
slug=slug,
sub_heading=sub_heading,
description=description,
image_urls=image_urls,
date_submitted=listing.createdAt,
status=prisma.enums.SubmissionStatus.PENDING,
runs=0,
rating=0.0,
)
except (
backend.server.v2.store.exceptions.AgentNotFoundError,
backend.server.v2.store.exceptions.ListingExistsError,
):
raise
except prisma.errors.PrismaError as e:
logger.error(f"Database error creating store submission: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to create store submission"
) from e
async def create_store_review(
user_id: str,
store_listing_version_id: str,
score: int,
comments: str | None = None,
) -> backend.server.v2.store.model.StoreReview:
try:
review = await prisma.models.StoreListingReview.prisma().upsert(
where={
"storeListingVersionId_reviewByUserId": {
"storeListingVersionId": store_listing_version_id,
"reviewByUserId": user_id,
}
},
data={
"create": {
"reviewByUserId": user_id,
"storeListingVersionId": store_listing_version_id,
"score": score,
"comments": comments,
},
"update": {
"score": score,
"comments": comments,
},
},
)
return backend.server.v2.store.model.StoreReview(
score=review.score,
comments=review.comments,
)
except prisma.errors.PrismaError as e:
logger.error(f"Database error creating store review: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to create store review"
) from e
async def get_user_profile(
user_id: str,
) -> backend.server.v2.store.model.ProfileDetails:
logger.debug(f"Getting user profile for {user_id}")
try:
profile = await prisma.models.Profile.prisma().find_first(
where={"userId": user_id} # type: ignore
)
if not profile:
logger.warning(f"Profile not found for user {user_id}")
await prisma.models.Profile.prisma().create(
data=prisma.types.ProfileCreateInput(
userId=user_id,
name="No Profile Data",
username=f"{random.choice(['happy', 'clever', 'swift', 'bright', 'wise'])}-{random.choice(['fox', 'wolf', 'bear', 'eagle', 'owl'])}_{random.randint(1000,9999)}",
description="No Profile Data",
links=[],
avatarUrl="",
)
)
return backend.server.v2.store.model.ProfileDetails(
name="No Profile Data",
username="No Profile Data",
description="No Profile Data",
links=[],
avatar_url="",
)
return backend.server.v2.store.model.ProfileDetails(
name=profile.name,
username=profile.username,
description=profile.description,
links=profile.links,
avatar_url=profile.avatarUrl,
)
except Exception as e:
logger.error(f"Error getting user profile: {str(e)}")
return backend.server.v2.store.model.ProfileDetails(
name="No Profile Data",
username="No Profile Data",
description="No Profile Data",
links=[],
avatar_url="",
)
async def update_or_create_profile(
user_id: str, profile: backend.server.v2.store.model.Profile
) -> backend.server.v2.store.model.CreatorDetails:
"""
Update the store profile for a user. Creates a new profile if one doesn't exist.
Only allows updating if the user_id matches the owning user.
Args:
user_id: ID of the authenticated user
profile: Updated profile details
Returns:
CreatorDetails: The updated profile
Raises:
HTTPException: If user is not authorized to update this profile
"""
logger.debug(f"Updating profile for user {user_id}")
try:
# Check if profile exists for user
existing_profile = await prisma.models.Profile.prisma().find_first(
where={"userId": user_id}
)
# If no profile exists, create a new one
if not existing_profile:
logger.debug(f"Creating new profile for user {user_id}")
# Create new profile since one doesn't exist
new_profile = await prisma.models.Profile.prisma().create(
data={
"userId": user_id,
"name": profile.name,
"username": profile.username,
"description": profile.description,
"links": profile.links,
"avatarUrl": profile.avatar_url,
}
)
return backend.server.v2.store.model.CreatorDetails(
name=new_profile.name,
username=new_profile.username,
description=new_profile.description,
links=new_profile.links,
avatar_url=new_profile.avatarUrl or "",
agent_rating=0.0,
agent_runs=0,
top_categories=[],
)
else:
logger.debug(f"Updating existing profile for user {user_id}")
# Update the existing profile
updated_profile = await prisma.models.Profile.prisma().update(
where={"id": existing_profile.id},
data=prisma.types.ProfileUpdateInput(
name=profile.name,
username=profile.username,
description=profile.description,
links=profile.links,
avatarUrl=profile.avatar_url,
),
)
if updated_profile is None:
logger.error(f"Failed to update profile for user {user_id}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to update profile"
)
return backend.server.v2.store.model.CreatorDetails(
name=updated_profile.name,
username=updated_profile.username,
description=updated_profile.description,
links=updated_profile.links,
avatar_url=updated_profile.avatarUrl or "",
agent_rating=0.0,
agent_runs=0,
top_categories=[],
)
except prisma.errors.PrismaError as e:
logger.error(f"Database error updating profile: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to update profile"
) from e
async def get_my_agents(
user_id: str,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.MyAgentsResponse:
logger.debug(f"Getting my agents for user {user_id}, page={page}")
try:
agents_with_max_version = await prisma.models.AgentGraph.prisma().find_many(
where=prisma.types.AgentGraphWhereInput(
userId=user_id, StoreListing={"none": {"isDeleted": False}}
),
order=[{"version": "desc"}],
distinct=["id"],
skip=(page - 1) * page_size,
take=page_size,
)
# store_listings = await prisma.models.StoreListing.prisma().find_many(
# where=prisma.types.StoreListingWhereInput(
# isDeleted=False,
# ),
# )
total = len(
await prisma.models.AgentGraph.prisma().find_many(
where=prisma.types.AgentGraphWhereInput(
userId=user_id, StoreListing={"none": {"isDeleted": False}}
),
order=[{"version": "desc"}],
distinct=["id"],
)
)
total_pages = (total + page_size - 1) // page_size
agents = agents_with_max_version
my_agents = [
backend.server.v2.store.model.MyAgent(
agent_id=agent.id,
agent_version=agent.version,
agent_name=agent.name or "",
last_edited=agent.updatedAt or agent.createdAt,
)
for agent in agents
]
return backend.server.v2.store.model.MyAgentsResponse(
agents=my_agents,
pagination=backend.server.v2.store.model.Pagination(
current_page=page,
total_items=total,
total_pages=total_pages,
page_size=page_size,
),
)
except Exception as e:
logger.error(f"Error getting my agents: {str(e)}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch my agents"
) from e

View File

@ -0,0 +1,260 @@
from datetime import datetime
import prisma.errors
import prisma.models
import pytest
from prisma import Prisma
import backend.server.v2.store.db as db
from backend.server.v2.store.model import Profile
@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_store_agents(mocker):
# Mock data
mock_agents = [
prisma.models.StoreAgent(
listing_id="test-id",
storeListingVersionId="version123",
slug="test-agent",
agent_name="Test Agent",
agent_video=None,
agent_image=["image.jpg"],
featured=False,
creator_username="creator",
creator_avatar="avatar.jpg",
sub_heading="Test heading",
description="Test description",
categories=[],
runs=10,
rating=4.5,
versions=["1.0"],
updated_at=datetime.now(),
)
]
# Mock prisma calls
mock_store_agent = mocker.patch("prisma.models.StoreAgent.prisma")
mock_store_agent.return_value.find_many = mocker.AsyncMock(return_value=mock_agents)
mock_store_agent.return_value.count = mocker.AsyncMock(return_value=1)
# Call function
result = await db.get_store_agents()
# Verify results
assert len(result.agents) == 1
assert result.agents[0].slug == "test-agent"
assert result.pagination.total_items == 1
# Verify mocks called correctly
mock_store_agent.return_value.find_many.assert_called_once()
mock_store_agent.return_value.count.assert_called_once()
@pytest.mark.asyncio
async def test_get_store_agent_details(mocker):
# Mock data
mock_agent = prisma.models.StoreAgent(
listing_id="test-id",
storeListingVersionId="version123",
slug="test-agent",
agent_name="Test Agent",
agent_video="video.mp4",
agent_image=["image.jpg"],
featured=False,
creator_username="creator",
creator_avatar="avatar.jpg",
sub_heading="Test heading",
description="Test description",
categories=["test"],
runs=10,
rating=4.5,
versions=["1.0"],
updated_at=datetime.now(),
)
# Mock prisma call
mock_store_agent = mocker.patch("prisma.models.StoreAgent.prisma")
mock_store_agent.return_value.find_first = mocker.AsyncMock(return_value=mock_agent)
# Call function
result = await db.get_store_agent_details("creator", "test-agent")
# Verify results
assert result.slug == "test-agent"
assert result.agent_name == "Test Agent"
# Verify mock called correctly
mock_store_agent.return_value.find_first.assert_called_once_with(
where={"creator_username": "creator", "slug": "test-agent"}
)
@pytest.mark.asyncio
async def test_get_store_creator_details(mocker):
# Mock data
mock_creator_data = prisma.models.Creator(
name="Test Creator",
username="creator",
description="Test description",
links=["link1"],
avatar_url="avatar.jpg",
num_agents=1,
agent_rating=4.5,
agent_runs=10,
top_categories=["test"],
)
# Mock prisma call
mock_creator = mocker.patch("prisma.models.Creator.prisma")
mock_creator.return_value.find_unique = mocker.AsyncMock()
# Configure the mock to return values that will pass validation
mock_creator.return_value.find_unique.return_value = mock_creator_data
# Call function
result = await db.get_store_creator_details("creator")
# Verify results
assert result.username == "creator"
assert result.name == "Test Creator"
assert result.description == "Test description"
assert result.avatar_url == "avatar.jpg"
# Verify mock called correctly
mock_creator.return_value.find_unique.assert_called_once_with(
where={"username": "creator"}
)
@pytest.mark.asyncio
async def test_create_store_submission(mocker):
# Mock data
mock_agent = prisma.models.AgentGraph(
id="agent-id",
version=1,
userId="user-id",
createdAt=datetime.now(),
isActive=True,
isTemplate=False,
)
mock_listing = prisma.models.StoreListing(
id="listing-id",
createdAt=datetime.now(),
updatedAt=datetime.now(),
isDeleted=False,
isApproved=False,
agentId="agent-id",
agentVersion=1,
owningUserId="user-id",
)
# Mock prisma calls
mock_agent_graph = mocker.patch("prisma.models.AgentGraph.prisma")
mock_agent_graph.return_value.find_first = mocker.AsyncMock(return_value=mock_agent)
mock_store_listing = mocker.patch("prisma.models.StoreListing.prisma")
mock_store_listing.return_value.find_first = mocker.AsyncMock(return_value=None)
mock_store_listing.return_value.create = mocker.AsyncMock(return_value=mock_listing)
# Call function
result = await db.create_store_submission(
user_id="user-id",
agent_id="agent-id",
agent_version=1,
slug="test-agent",
name="Test Agent",
description="Test description",
)
# Verify results
assert result.name == "Test Agent"
assert result.description == "Test description"
# Verify mocks called correctly
mock_agent_graph.return_value.find_first.assert_called_once()
mock_store_listing.return_value.find_first.assert_called_once()
mock_store_listing.return_value.create.assert_called_once()
@pytest.mark.asyncio
async def test_update_profile(mocker):
# Mock data
mock_profile = prisma.models.Profile(
id="profile-id",
name="Test Creator",
username="creator",
description="Test description",
links=["link1"],
avatarUrl="avatar.jpg",
createdAt=datetime.now(),
updatedAt=datetime.now(),
)
# Mock prisma calls
mock_profile_db = mocker.patch("prisma.models.Profile.prisma")
mock_profile_db.return_value.find_first = mocker.AsyncMock(
return_value=mock_profile
)
mock_profile_db.return_value.update = mocker.AsyncMock(return_value=mock_profile)
# Test data
profile = Profile(
name="Test Creator",
username="creator",
description="Test description",
links=["link1"],
avatar_url="avatar.jpg",
)
# Call function
result = await db.update_or_create_profile("user-id", profile)
# Verify results
assert result.username == "creator"
assert result.name == "Test Creator"
# Verify mocks called correctly
mock_profile_db.return_value.find_first.assert_called_once()
mock_profile_db.return_value.update.assert_called_once()
@pytest.mark.asyncio
async def test_get_user_profile(mocker):
# Mock data
mock_profile = prisma.models.Profile(
id="profile-id",
name="No Profile Data",
username="testuser",
description="Test description",
links=["link1", "link2"],
avatarUrl="avatar.jpg",
createdAt=datetime.now(),
updatedAt=datetime.now(),
)
# Mock prisma calls
mock_profile_db = mocker.patch("prisma.models.Profile.prisma")
mock_profile_db.return_value.find_unique = mocker.AsyncMock(
return_value=mock_profile
)
# Call function
result = await db.get_user_profile("user-id")
# Verify results
assert result.name == "No Profile Data"
assert result.username == "No Profile Data"
assert result.description == "No Profile Data"
assert result.links == []
assert result.avatar_url == ""

View File

@ -0,0 +1,76 @@
class MediaUploadError(Exception):
"""Base exception for media upload errors"""
pass
class InvalidFileTypeError(MediaUploadError):
"""Raised when file type is not supported"""
pass
class FileSizeTooLargeError(MediaUploadError):
"""Raised when file size exceeds maximum limit"""
pass
class FileReadError(MediaUploadError):
"""Raised when there's an error reading the file"""
pass
class StorageConfigError(MediaUploadError):
"""Raised when storage configuration is invalid"""
pass
class StorageUploadError(MediaUploadError):
"""Raised when upload to storage fails"""
pass
class StoreError(Exception):
"""Base exception for store-related errors"""
pass
class AgentNotFoundError(StoreError):
"""Raised when an agent is not found"""
pass
class CreatorNotFoundError(StoreError):
"""Raised when a creator is not found"""
pass
class ListingExistsError(StoreError):
"""Raised when trying to create a listing that already exists"""
pass
class DatabaseError(StoreError):
"""Raised when there is an error interacting with the database"""
pass
class ProfileNotFoundError(StoreError):
"""Raised when a profile is not found"""
pass
class SubmissionNotFoundError(StoreError):
"""Raised when a submission is not found"""
pass

View File

@ -0,0 +1,157 @@
import logging
import os
import uuid
import fastapi
from google.cloud import storage
import backend.server.v2.store.exceptions
from backend.util.settings import Settings
logger = logging.getLogger(__name__)
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/webm"}
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
async def upload_media(user_id: str, file: fastapi.UploadFile) -> str:
# Get file content for deeper validation
try:
content = await file.read(1024) # Read first 1KB for validation
await file.seek(0) # Reset file pointer
except Exception as e:
logger.error(f"Error reading file content: {str(e)}")
raise backend.server.v2.store.exceptions.FileReadError(
"Failed to read file content"
) from e
# Validate file signature/magic bytes
if file.content_type in ALLOWED_IMAGE_TYPES:
# Check image file signatures
if content.startswith(b"\xFF\xD8\xFF"): # JPEG
if file.content_type != "image/jpeg":
raise backend.server.v2.store.exceptions.InvalidFileTypeError(
"File signature does not match content type"
)
elif content.startswith(b"\x89PNG\r\n\x1a\n"): # PNG
if file.content_type != "image/png":
raise backend.server.v2.store.exceptions.InvalidFileTypeError(
"File signature does not match content type"
)
elif content.startswith(b"GIF87a") or content.startswith(b"GIF89a"): # GIF
if file.content_type != "image/gif":
raise backend.server.v2.store.exceptions.InvalidFileTypeError(
"File signature does not match content type"
)
elif content.startswith(b"RIFF") and content[8:12] == b"WEBP": # WebP
if file.content_type != "image/webp":
raise backend.server.v2.store.exceptions.InvalidFileTypeError(
"File signature does not match content type"
)
else:
raise backend.server.v2.store.exceptions.InvalidFileTypeError(
"Invalid image file signature"
)
elif file.content_type in ALLOWED_VIDEO_TYPES:
# Check video file signatures
if content.startswith(b"\x00\x00\x00") and (content[4:8] == b"ftyp"): # MP4
if file.content_type != "video/mp4":
raise backend.server.v2.store.exceptions.InvalidFileTypeError(
"File signature does not match content type"
)
elif content.startswith(b"\x1a\x45\xdf\xa3"): # WebM
if file.content_type != "video/webm":
raise backend.server.v2.store.exceptions.InvalidFileTypeError(
"File signature does not match content type"
)
else:
raise backend.server.v2.store.exceptions.InvalidFileTypeError(
"Invalid video file signature"
)
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")
raise backend.server.v2.store.exceptions.StorageConfigError(
"Missing storage configuration"
)
try:
# Validate file type
content_type = file.content_type
if (
content_type not in ALLOWED_IMAGE_TYPES
and content_type not in ALLOWED_VIDEO_TYPES
):
logger.warning(f"Invalid file type attempted: {content_type}")
raise backend.server.v2.store.exceptions.InvalidFileTypeError(
f"File type not supported. Must be jpeg, png, gif, webp, mp4 or webm. Content type: {content_type}"
)
# Validate file size
file_size = 0
chunk_size = 8192 # 8KB chunks
try:
while chunk := await file.read(chunk_size):
file_size += len(chunk)
if file_size > MAX_FILE_SIZE:
logger.warning(f"File size too large: {file_size} bytes")
raise backend.server.v2.store.exceptions.FileSizeTooLargeError(
"File too large. Maximum size is 50MB"
)
except backend.server.v2.store.exceptions.FileSizeTooLargeError:
raise
except Exception as e:
logger.error(f"Error reading file chunks: {str(e)}")
raise backend.server.v2.store.exceptions.FileReadError(
"Failed to read uploaded file"
) from e
# Reset file pointer
await file.seek(0)
# Generate unique filename
filename = file.filename or ""
file_ext = os.path.splitext(filename)[1].lower()
unique_filename = f"{uuid.uuid4()}{file_ext}"
# Construct storage path
media_type = "images" if content_type in ALLOWED_IMAGE_TYPES else "videos"
storage_path = f"users/{user_id}/{media_type}/{unique_filename}"
try:
storage_client = storage.Client()
bucket = storage_client.bucket(settings.config.media_gcs_bucket_name)
blob = bucket.blob(storage_path)
blob.content_type = content_type
file_bytes = await file.read()
blob.upload_from_string(file_bytes, content_type=content_type)
public_url = blob.public_url
logger.info(f"Successfully uploaded file to: {storage_path}")
return public_url
except Exception as e:
logger.error(f"GCS storage error: {str(e)}")
raise backend.server.v2.store.exceptions.StorageUploadError(
"Failed to upload file to storage"
) from e
except backend.server.v2.store.exceptions.MediaUploadError:
raise
except Exception as e:
logger.exception("Unexpected error in upload_media")
raise backend.server.v2.store.exceptions.MediaUploadError(
"Unexpected error during media upload"
) from e

View File

@ -0,0 +1,190 @@
import io
import unittest.mock
import fastapi
import pytest
import starlette.datastructures
import backend.server.v2.store.exceptions
import backend.server.v2.store.media
from backend.util.settings import Settings
@pytest.fixture
def mock_settings(monkeypatch):
settings = Settings()
settings.config.media_gcs_bucket_name = "test-bucket"
settings.config.google_application_credentials = "test-credentials"
monkeypatch.setattr("backend.server.v2.store.media.Settings", lambda: settings)
return settings
@pytest.fixture
def mock_storage_client(mocker):
mock_client = unittest.mock.MagicMock()
mock_bucket = unittest.mock.MagicMock()
mock_blob = unittest.mock.MagicMock()
mock_client.bucket.return_value = mock_bucket
mock_bucket.blob.return_value = mock_blob
mock_blob.public_url = "http://test-url/media/laptop.jpeg"
mocker.patch("google.cloud.storage.Client", return_value=mock_client)
return mock_client
async def test_upload_media_success(mock_settings, mock_storage_client):
# Create test JPEG data with valid signature
test_data = b"\xFF\xD8\xFF" + b"test data"
test_file = fastapi.UploadFile(
filename="laptop.jpeg",
file=io.BytesIO(test_data),
headers=starlette.datastructures.Headers({"content-type": "image/jpeg"}),
)
result = await backend.server.v2.store.media.upload_media("test-user", test_file)
assert result == "http://test-url/media/laptop.jpeg"
mock_bucket = mock_storage_client.bucket.return_value
mock_blob = mock_bucket.blob.return_value
mock_blob.upload_from_string.assert_called_once()
async def test_upload_media_invalid_type(mock_settings, mock_storage_client):
test_file = fastapi.UploadFile(
filename="test.txt",
file=io.BytesIO(b"test data"),
headers=starlette.datastructures.Headers({"content-type": "text/plain"}),
)
with pytest.raises(backend.server.v2.store.exceptions.InvalidFileTypeError):
await backend.server.v2.store.media.upload_media("test-user", test_file)
mock_bucket = mock_storage_client.bucket.return_value
mock_blob = mock_bucket.blob.return_value
mock_blob.upload_from_string.assert_not_called()
async def test_upload_media_missing_credentials(monkeypatch):
settings = Settings()
settings.config.media_gcs_bucket_name = ""
settings.config.google_application_credentials = ""
monkeypatch.setattr("backend.server.v2.store.media.Settings", lambda: settings)
test_file = fastapi.UploadFile(
filename="laptop.jpeg",
file=io.BytesIO(b"\xFF\xD8\xFF" + b"test data"), # Valid JPEG signature
headers=starlette.datastructures.Headers({"content-type": "image/jpeg"}),
)
with pytest.raises(backend.server.v2.store.exceptions.StorageConfigError):
await backend.server.v2.store.media.upload_media("test-user", test_file)
async def test_upload_media_video_type(mock_settings, mock_storage_client):
test_file = fastapi.UploadFile(
filename="test.mp4",
file=io.BytesIO(b"\x00\x00\x00\x18ftypmp42"), # Valid MP4 signature
headers=starlette.datastructures.Headers({"content-type": "video/mp4"}),
)
result = await backend.server.v2.store.media.upload_media("test-user", test_file)
assert result == "http://test-url/media/laptop.jpeg"
mock_bucket = mock_storage_client.bucket.return_value
mock_blob = mock_bucket.blob.return_value
mock_blob.upload_from_string.assert_called_once()
async def test_upload_media_file_too_large(mock_settings, mock_storage_client):
large_data = b"\xFF\xD8\xFF" + b"x" * (
50 * 1024 * 1024 + 1
) # 50MB + 1 byte with valid JPEG signature
test_file = fastapi.UploadFile(
filename="laptop.jpeg",
file=io.BytesIO(large_data),
headers=starlette.datastructures.Headers({"content-type": "image/jpeg"}),
)
with pytest.raises(backend.server.v2.store.exceptions.FileSizeTooLargeError):
await backend.server.v2.store.media.upload_media("test-user", test_file)
async def test_upload_media_file_read_error(mock_settings, mock_storage_client):
test_file = fastapi.UploadFile(
filename="laptop.jpeg",
file=io.BytesIO(b""), # Empty file that will raise error on read
headers=starlette.datastructures.Headers({"content-type": "image/jpeg"}),
)
test_file.read = unittest.mock.AsyncMock(side_effect=Exception("Read error"))
with pytest.raises(backend.server.v2.store.exceptions.FileReadError):
await backend.server.v2.store.media.upload_media("test-user", test_file)
async def test_upload_media_png_success(mock_settings, mock_storage_client):
test_file = fastapi.UploadFile(
filename="test.png",
file=io.BytesIO(b"\x89PNG\r\n\x1a\n"), # Valid PNG signature
headers=starlette.datastructures.Headers({"content-type": "image/png"}),
)
result = await backend.server.v2.store.media.upload_media("test-user", test_file)
assert result == "http://test-url/media/laptop.jpeg"
async def test_upload_media_gif_success(mock_settings, mock_storage_client):
test_file = fastapi.UploadFile(
filename="test.gif",
file=io.BytesIO(b"GIF89a"), # Valid GIF signature
headers=starlette.datastructures.Headers({"content-type": "image/gif"}),
)
result = await backend.server.v2.store.media.upload_media("test-user", test_file)
assert result == "http://test-url/media/laptop.jpeg"
async def test_upload_media_webp_success(mock_settings, mock_storage_client):
test_file = fastapi.UploadFile(
filename="test.webp",
file=io.BytesIO(b"RIFF\x00\x00\x00\x00WEBP"), # Valid WebP signature
headers=starlette.datastructures.Headers({"content-type": "image/webp"}),
)
result = await backend.server.v2.store.media.upload_media("test-user", test_file)
assert result == "http://test-url/media/laptop.jpeg"
async def test_upload_media_webm_success(mock_settings, mock_storage_client):
test_file = fastapi.UploadFile(
filename="test.webm",
file=io.BytesIO(b"\x1a\x45\xdf\xa3"), # Valid WebM signature
headers=starlette.datastructures.Headers({"content-type": "video/webm"}),
)
result = await backend.server.v2.store.media.upload_media("test-user", test_file)
assert result == "http://test-url/media/laptop.jpeg"
async def test_upload_media_mismatched_signature(mock_settings, mock_storage_client):
test_file = fastapi.UploadFile(
filename="test.jpeg",
file=io.BytesIO(b"\x89PNG\r\n\x1a\n"), # PNG signature with JPEG content type
headers=starlette.datastructures.Headers({"content-type": "image/jpeg"}),
)
with pytest.raises(backend.server.v2.store.exceptions.InvalidFileTypeError):
await backend.server.v2.store.media.upload_media("test-user", test_file)
async def test_upload_media_invalid_signature(mock_settings, mock_storage_client):
test_file = fastapi.UploadFile(
filename="test.jpeg",
file=io.BytesIO(b"invalid signature"),
headers=starlette.datastructures.Headers({"content-type": "image/jpeg"}),
)
with pytest.raises(backend.server.v2.store.exceptions.InvalidFileTypeError):
await backend.server.v2.store.media.upload_media("test-user", test_file)

View File

@ -0,0 +1,150 @@
import datetime
from typing import List
import prisma.enums
import pydantic
class Pagination(pydantic.BaseModel):
total_items: int = pydantic.Field(
description="Total number of items.", examples=[42]
)
total_pages: int = pydantic.Field(
description="Total number of pages.", examples=[97]
)
current_page: int = pydantic.Field(
description="Current_page page number.", examples=[1]
)
page_size: int = pydantic.Field(
description="Number of items per page.", examples=[25]
)
class MyAgent(pydantic.BaseModel):
agent_id: str
agent_version: int
agent_name: str
last_edited: datetime.datetime
class MyAgentsResponse(pydantic.BaseModel):
agents: list[MyAgent]
pagination: Pagination
class StoreAgent(pydantic.BaseModel):
slug: str
agent_name: str
agent_image: str
creator: str
creator_avatar: str
sub_heading: str
description: str
runs: int
rating: float
class StoreAgentsResponse(pydantic.BaseModel):
agents: list[StoreAgent]
pagination: Pagination
class StoreAgentDetails(pydantic.BaseModel):
store_listing_version_id: str
slug: str
agent_name: str
agent_video: str
agent_image: list[str]
creator: str
creator_avatar: str
sub_heading: str
description: str
categories: list[str]
runs: int
rating: float
versions: list[str]
last_updated: datetime.datetime
class Creator(pydantic.BaseModel):
name: str
username: str
description: str
avatar_url: str
num_agents: int
agent_rating: float
agent_runs: int
class CreatorsResponse(pydantic.BaseModel):
creators: List[Creator]
pagination: Pagination
class CreatorDetails(pydantic.BaseModel):
name: str
username: str
description: str
links: list[str]
avatar_url: str
agent_rating: float
agent_runs: int
top_categories: list[str]
class Profile(pydantic.BaseModel):
name: str
username: str
description: str
links: list[str]
avatar_url: str
class StoreSubmission(pydantic.BaseModel):
agent_id: str
agent_version: int
name: str
sub_heading: str
slug: str
description: str
image_urls: list[str]
date_submitted: datetime.datetime
status: prisma.enums.SubmissionStatus
runs: int
rating: float
class StoreSubmissionsResponse(pydantic.BaseModel):
submissions: list[StoreSubmission]
pagination: Pagination
class StoreSubmissionRequest(pydantic.BaseModel):
agent_id: str
agent_version: int
slug: str
name: str
sub_heading: str
video_url: str | None = None
image_urls: list[str] = []
description: str = ""
categories: list[str] = []
class ProfileDetails(pydantic.BaseModel):
name: str
username: str
description: str
links: list[str]
avatar_url: str | None = None
class StoreReview(pydantic.BaseModel):
score: int
comments: str | None = None
class StoreReviewCreate(pydantic.BaseModel):
store_listing_version_id: str
score: int
comments: str | None = None

View File

@ -0,0 +1,193 @@
import datetime
import prisma.enums
import backend.server.v2.store.model
def test_pagination():
pagination = backend.server.v2.store.model.Pagination(
total_items=100, total_pages=5, current_page=2, page_size=20
)
assert pagination.total_items == 100
assert pagination.total_pages == 5
assert pagination.current_page == 2
assert pagination.page_size == 20
def test_store_agent():
agent = backend.server.v2.store.model.StoreAgent(
slug="test-agent",
agent_name="Test Agent",
agent_image="test.jpg",
creator="creator1",
creator_avatar="avatar.jpg",
sub_heading="Test subheading",
description="Test description",
runs=50,
rating=4.5,
)
assert agent.slug == "test-agent"
assert agent.agent_name == "Test Agent"
assert agent.runs == 50
assert agent.rating == 4.5
def test_store_agents_response():
response = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug="test-agent",
agent_name="Test Agent",
agent_image="test.jpg",
creator="creator1",
creator_avatar="avatar.jpg",
sub_heading="Test subheading",
description="Test description",
runs=50,
rating=4.5,
)
],
pagination=backend.server.v2.store.model.Pagination(
total_items=1, total_pages=1, current_page=1, page_size=20
),
)
assert len(response.agents) == 1
assert response.pagination.total_items == 1
def test_store_agent_details():
details = backend.server.v2.store.model.StoreAgentDetails(
store_listing_version_id="version123",
slug="test-agent",
agent_name="Test Agent",
agent_video="video.mp4",
agent_image=["image1.jpg", "image2.jpg"],
creator="creator1",
creator_avatar="avatar.jpg",
sub_heading="Test subheading",
description="Test description",
categories=["cat1", "cat2"],
runs=50,
rating=4.5,
versions=["1.0", "2.0"],
last_updated=datetime.datetime.now(),
)
assert details.slug == "test-agent"
assert len(details.agent_image) == 2
assert len(details.categories) == 2
assert len(details.versions) == 2
def test_creator():
creator = backend.server.v2.store.model.Creator(
agent_rating=4.8,
agent_runs=1000,
name="Test Creator",
username="creator1",
description="Test description",
avatar_url="avatar.jpg",
num_agents=5,
)
assert creator.name == "Test Creator"
assert creator.num_agents == 5
def test_creators_response():
response = backend.server.v2.store.model.CreatorsResponse(
creators=[
backend.server.v2.store.model.Creator(
agent_rating=4.8,
agent_runs=1000,
name="Test Creator",
username="creator1",
description="Test description",
avatar_url="avatar.jpg",
num_agents=5,
)
],
pagination=backend.server.v2.store.model.Pagination(
total_items=1, total_pages=1, current_page=1, page_size=20
),
)
assert len(response.creators) == 1
assert response.pagination.total_items == 1
def test_creator_details():
details = backend.server.v2.store.model.CreatorDetails(
name="Test Creator",
username="creator1",
description="Test description",
links=["link1.com", "link2.com"],
avatar_url="avatar.jpg",
agent_rating=4.8,
agent_runs=1000,
top_categories=["cat1", "cat2"],
)
assert details.name == "Test Creator"
assert len(details.links) == 2
assert details.agent_rating == 4.8
assert len(details.top_categories) == 2
def test_store_submission():
submission = backend.server.v2.store.model.StoreSubmission(
agent_id="agent123",
agent_version=1,
sub_heading="Test subheading",
name="Test Agent",
slug="test-agent",
description="Test description",
image_urls=["image1.jpg", "image2.jpg"],
date_submitted=datetime.datetime(2023, 1, 1),
status=prisma.enums.SubmissionStatus.PENDING,
runs=50,
rating=4.5,
)
assert submission.name == "Test Agent"
assert len(submission.image_urls) == 2
assert submission.status == prisma.enums.SubmissionStatus.PENDING
def test_store_submissions_response():
response = backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=[
backend.server.v2.store.model.StoreSubmission(
agent_id="agent123",
agent_version=1,
sub_heading="Test subheading",
name="Test Agent",
slug="test-agent",
description="Test description",
image_urls=["image1.jpg"],
date_submitted=datetime.datetime(2023, 1, 1),
status=prisma.enums.SubmissionStatus.PENDING,
runs=50,
rating=4.5,
)
],
pagination=backend.server.v2.store.model.Pagination(
total_items=1, total_pages=1, current_page=1, page_size=20
),
)
assert len(response.submissions) == 1
assert response.pagination.total_items == 1
def test_store_submission_request():
request = backend.server.v2.store.model.StoreSubmissionRequest(
agent_id="agent123",
agent_version=1,
slug="test-agent",
name="Test Agent",
sub_heading="Test subheading",
video_url="video.mp4",
image_urls=["image1.jpg", "image2.jpg"],
description="Test description",
categories=["cat1", "cat2"],
)
assert request.agent_id == "agent123"
assert request.agent_version == 1
assert len(request.image_urls) == 2
assert len(request.categories) == 2

View File

@ -0,0 +1,439 @@
import logging
import typing
import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware
import fastapi
import fastapi.responses
import backend.server.v2.store.db
import backend.server.v2.store.media
import backend.server.v2.store.model
logger = logging.getLogger(__name__)
router = fastapi.APIRouter()
##############################################
############### Profile Endpoints ############
##############################################
@router.get("/profile", tags=["store", "private"])
async def get_profile(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
]
) -> backend.server.v2.store.model.ProfileDetails:
"""
Get the profile details for the authenticated user.
"""
try:
profile = await backend.server.v2.store.db.get_user_profile(user_id)
return profile
except Exception:
logger.exception("Exception occurred whilst getting user profile")
raise
@router.post(
"/profile",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def update_or_create_profile(
profile: backend.server.v2.store.model.Profile,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> backend.server.v2.store.model.CreatorDetails:
"""
Update the store profile for the authenticated user.
Args:
profile (Profile): The updated profile details
user_id (str): ID of the authenticated user
Returns:
CreatorDetails: The updated profile
Raises:
HTTPException: If there is an error updating the profile
"""
try:
updated_profile = await backend.server.v2.store.db.update_or_create_profile(
user_id=user_id, profile=profile
)
return updated_profile
except Exception:
logger.exception("Exception occurred whilst updating profile")
raise
##############################################
############### Agent Endpoints ##############
##############################################
@router.get("/agents", tags=["store", "public"])
async def get_agents(
featured: bool = False,
creator: str | None = None,
sorted_by: str | None = None,
search_query: str | None = None,
category: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreAgentsResponse:
"""
Get a paginated list of agents from the store with optional filtering and sorting.
Args:
featured (bool, optional): Filter to only show featured agents. Defaults to False.
creator (str | None, optional): Filter agents by creator username. Defaults to None.
sorted_by (str | None, optional): Sort agents by "runs" or "rating". Defaults to None.
search_query (str | None, optional): Search agents by name, subheading and description. Defaults to None.
category (str | None, optional): Filter agents by category. Defaults to None.
page (int, optional): Page number for pagination. Defaults to 1.
page_size (int, optional): Number of agents per page. Defaults to 20.
Returns:
StoreAgentsResponse: Paginated list of agents matching the filters
Raises:
HTTPException: If page or page_size are less than 1
Used for:
- Home Page Featured Agents
- Home Page Top Agents
- Search Results
- Agent Details - Other Agents By Creator
- Agent Details - Similar Agents
- Creator Details - Agents By Creator
"""
if page < 1:
raise fastapi.HTTPException(
status_code=422, detail="Page must be greater than 0"
)
if page_size < 1:
raise fastapi.HTTPException(
status_code=422, detail="Page size must be greater than 0"
)
try:
agents = await backend.server.v2.store.db.get_store_agents(
featured=featured,
creator=creator,
sorted_by=sorted_by,
search_query=search_query,
category=category,
page=page,
page_size=page_size,
)
return agents
except Exception:
logger.exception("Exception occured whilst getting store agents")
raise
@router.get("/agents/{username}/{agent_name}", tags=["store", "public"])
async def get_agent(
username: str, agent_name: str
) -> backend.server.v2.store.model.StoreAgentDetails:
"""
This is only used on the AgentDetails Page
It returns the store listing agents details.
"""
try:
agent = await backend.server.v2.store.db.get_store_agent_details(
username=username, agent_name=agent_name
)
return agent
except Exception:
logger.exception("Exception occurred whilst getting store agent details")
raise
@router.post(
"/agents/{username}/{agent_name}/review",
tags=["store"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def create_review(
username: str,
agent_name: str,
review: backend.server.v2.store.model.StoreReviewCreate,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> backend.server.v2.store.model.StoreReview:
"""
Create a review for a store agent.
Args:
username: Creator's username
agent_name: Name/slug of the agent
review: Review details including score and optional comments
user_id: ID of authenticated user creating the review
Returns:
The created review
"""
try:
# Create the review
created_review = await backend.server.v2.store.db.create_store_review(
user_id=user_id,
store_listing_version_id=review.store_listing_version_id,
score=review.score,
comments=review.comments,
)
return created_review
except Exception:
logger.exception("Exception occurred whilst creating store review")
raise
##############################################
############# Creator Endpoints #############
##############################################
@router.get("/creators", tags=["store", "public"])
async def get_creators(
featured: bool = False,
search_query: str | None = None,
sorted_by: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.CreatorsResponse:
"""
This is needed for:
- Home Page Featured Creators
- Search Results Page
---
To support this functionality we need:
- featured: bool - to limit the list to just featured agents
- search_query: str - vector search based on the creators profile description.
- sorted_by: [agent_rating, agent_runs] -
"""
if page < 1:
raise fastapi.HTTPException(
status_code=422, detail="Page must be greater than 0"
)
if page_size < 1:
raise fastapi.HTTPException(
status_code=422, detail="Page size must be greater than 0"
)
try:
creators = await backend.server.v2.store.db.get_store_creators(
featured=featured,
search_query=search_query,
sorted_by=sorted_by,
page=page,
page_size=page_size,
)
return creators
except Exception:
logger.exception("Exception occurred whilst getting store creators")
raise
@router.get("/creator/{username}", tags=["store", "public"])
async def get_creator(username: str) -> backend.server.v2.store.model.CreatorDetails:
"""
Get the details of a creator
- Creator Details Page
"""
try:
creator = await backend.server.v2.store.db.get_store_creator_details(
username=username
)
return creator
except Exception:
logger.exception("Exception occurred whilst getting creator details")
raise
############################################
############# Store Submissions ###############
############################################
@router.get(
"/myagents",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def get_my_agents(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
]
) -> backend.server.v2.store.model.MyAgentsResponse:
try:
agents = await backend.server.v2.store.db.get_my_agents(user_id)
return agents
except Exception:
logger.exception("Exception occurred whilst getting my agents")
raise
@router.delete(
"/submissions/{submission_id}",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def delete_submission(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
submission_id: str,
) -> bool:
"""
Delete a store listing submission.
Args:
user_id (str): ID of the authenticated user
submission_id (str): ID of the submission to be deleted
Returns:
bool: True if the submission was successfully deleted, False otherwise
"""
try:
result = await backend.server.v2.store.db.delete_store_submission(
user_id=user_id,
submission_id=submission_id,
)
return result
except Exception:
logger.exception("Exception occurred whilst deleting store submission")
raise
@router.get(
"/submissions",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def get_submissions(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreSubmissionsResponse:
"""
Get a paginated list of store submissions for the authenticated user.
Args:
user_id (str): ID of the authenticated user
page (int, optional): Page number for pagination. Defaults to 1.
page_size (int, optional): Number of submissions per page. Defaults to 20.
Returns:
StoreListingsResponse: Paginated list of store submissions
Raises:
HTTPException: If page or page_size are less than 1
"""
if page < 1:
raise fastapi.HTTPException(
status_code=422, detail="Page must be greater than 0"
)
if page_size < 1:
raise fastapi.HTTPException(
status_code=422, detail="Page size must be greater than 0"
)
try:
listings = await backend.server.v2.store.db.get_store_submissions(
user_id=user_id,
page=page,
page_size=page_size,
)
return listings
except Exception:
logger.exception("Exception occurred whilst getting store submissions")
raise
@router.post(
"/submissions",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def create_submission(
submission_request: backend.server.v2.store.model.StoreSubmissionRequest,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> backend.server.v2.store.model.StoreSubmission:
"""
Create a new store listing submission.
Args:
submission_request (StoreSubmissionRequest): The submission details
user_id (str): ID of the authenticated user submitting the listing
Returns:
StoreSubmission: The created store submission
Raises:
HTTPException: If there is an error creating the submission
"""
try:
submission = await backend.server.v2.store.db.create_store_submission(
user_id=user_id,
agent_id=submission_request.agent_id,
agent_version=submission_request.agent_version,
slug=submission_request.slug,
name=submission_request.name,
video_url=submission_request.video_url,
image_urls=submission_request.image_urls,
description=submission_request.description,
sub_heading=submission_request.sub_heading,
categories=submission_request.categories,
)
return submission
except Exception:
logger.exception("Exception occurred whilst creating store submission")
raise
@router.post(
"/submissions/media",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def upload_submission_media(
file: fastapi.UploadFile,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> str:
"""
Upload media (images/videos) for a store listing submission.
Args:
file (UploadFile): The media file to upload
user_id (str): ID of the authenticated user uploading the media
Returns:
str: URL of the uploaded media file
Raises:
HTTPException: If there is an error uploading the media
"""
try:
media_url = await backend.server.v2.store.media.upload_media(
user_id=user_id, file=file
)
return media_url
except Exception:
logger.exception("Exception occurred whilst uploading submission media")
raise

View File

@ -0,0 +1,551 @@
import datetime
import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware
import fastapi
import fastapi.testclient
import prisma.enums
import pytest_mock
import backend.server.v2.store.model
import backend.server.v2.store.routes
app = fastapi.FastAPI()
app.include_router(backend.server.v2.store.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_agents_defaults(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[],
pagination=backend.server.v2.store.model.Pagination(
current_page=0,
total_items=0,
total_pages=0,
page_size=10,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert data.pagination.total_pages == 0
assert data.agents == []
mock_db_call.assert_called_once_with(
featured=False,
creator=None,
sorted_by=None,
search_query=None,
category=None,
page=1,
page_size=20,
)
def test_get_agents_featured(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug="featured-agent",
agent_name="Featured Agent",
agent_image="featured.jpg",
creator="creator1",
creator_avatar="avatar1.jpg",
sub_heading="Featured agent subheading",
description="Featured agent description",
runs=100,
rating=4.5,
)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=1,
total_items=1,
total_pages=1,
page_size=20,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents?featured=true")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert len(data.agents) == 1
assert data.agents[0].slug == "featured-agent"
mock_db_call.assert_called_once_with(
featured=True,
creator=None,
sorted_by=None,
search_query=None,
category=None,
page=1,
page_size=20,
)
def test_get_agents_by_creator(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug="creator-agent",
agent_name="Creator Agent",
agent_image="agent.jpg",
creator="specific-creator",
creator_avatar="avatar.jpg",
sub_heading="Creator agent subheading",
description="Creator agent description",
runs=50,
rating=4.0,
)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=1,
total_items=1,
total_pages=1,
page_size=20,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents?creator=specific-creator")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert len(data.agents) == 1
assert data.agents[0].creator == "specific-creator"
mock_db_call.assert_called_once_with(
featured=False,
creator="specific-creator",
sorted_by=None,
search_query=None,
category=None,
page=1,
page_size=20,
)
def test_get_agents_sorted(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug="top-agent",
agent_name="Top Agent",
agent_image="top.jpg",
creator="creator1",
creator_avatar="avatar1.jpg",
sub_heading="Top agent subheading",
description="Top agent description",
runs=1000,
rating=5.0,
)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=1,
total_items=1,
total_pages=1,
page_size=20,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents?sorted_by=runs")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert len(data.agents) == 1
assert data.agents[0].runs == 1000
mock_db_call.assert_called_once_with(
featured=False,
creator=None,
sorted_by="runs",
search_query=None,
category=None,
page=1,
page_size=20,
)
def test_get_agents_search(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug="search-agent",
agent_name="Search Agent",
agent_image="search.jpg",
creator="creator1",
creator_avatar="avatar1.jpg",
sub_heading="Search agent subheading",
description="Specific search term description",
runs=75,
rating=4.2,
)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=1,
total_items=1,
total_pages=1,
page_size=20,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents?search_query=specific")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert len(data.agents) == 1
assert "specific" in data.agents[0].description.lower()
mock_db_call.assert_called_once_with(
featured=False,
creator=None,
sorted_by=None,
search_query="specific",
category=None,
page=1,
page_size=20,
)
def test_get_agents_category(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug="category-agent",
agent_name="Category Agent",
agent_image="category.jpg",
creator="creator1",
creator_avatar="avatar1.jpg",
sub_heading="Category agent subheading",
description="Category agent description",
runs=60,
rating=4.1,
)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=1,
total_items=1,
total_pages=1,
page_size=20,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents?category=test-category")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert len(data.agents) == 1
mock_db_call.assert_called_once_with(
featured=False,
creator=None,
sorted_by=None,
search_query=None,
category="test-category",
page=1,
page_size=20,
)
def test_get_agents_pagination(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentsResponse(
agents=[
backend.server.v2.store.model.StoreAgent(
slug=f"agent-{i}",
agent_name=f"Agent {i}",
agent_image=f"agent{i}.jpg",
creator="creator1",
creator_avatar="avatar1.jpg",
sub_heading=f"Agent {i} subheading",
description=f"Agent {i} description",
runs=i * 10,
rating=4.0,
)
for i in range(5)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=2,
total_items=15,
total_pages=3,
page_size=5,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.return_value = mocked_value
response = client.get("/agents?page=2&page_size=5")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentsResponse.model_validate(
response.json()
)
assert len(data.agents) == 5
assert data.pagination.current_page == 2
assert data.pagination.page_size == 5
mock_db_call.assert_called_once_with(
featured=False,
creator=None,
sorted_by=None,
search_query=None,
category=None,
page=2,
page_size=5,
)
def test_get_agents_malformed_request(mocker: pytest_mock.MockFixture):
# Test with invalid page number
response = client.get("/agents?page=-1")
assert response.status_code == 422
# Test with invalid page size
response = client.get("/agents?page_size=0")
assert response.status_code == 422
# Test with non-numeric values
response = client.get("/agents?page=abc&page_size=def")
assert response.status_code == 422
# Verify no DB calls were made
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agents")
mock_db_call.assert_not_called()
def test_get_agent_details(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreAgentDetails(
store_listing_version_id="test-version-id",
slug="test-agent",
agent_name="Test Agent",
agent_video="video.mp4",
agent_image=["image1.jpg", "image2.jpg"],
creator="creator1",
creator_avatar="avatar1.jpg",
sub_heading="Test agent subheading",
description="Test agent description",
categories=["category1", "category2"],
runs=100,
rating=4.5,
versions=["1.0.0", "1.1.0"],
last_updated=datetime.datetime.now(),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agent_details")
mock_db_call.return_value = mocked_value
response = client.get("/agents/creator1/test-agent")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreAgentDetails.model_validate(
response.json()
)
assert data.agent_name == "Test Agent"
assert data.creator == "creator1"
mock_db_call.assert_called_once_with(username="creator1", agent_name="test-agent")
def test_get_creators_defaults(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.CreatorsResponse(
creators=[],
pagination=backend.server.v2.store.model.Pagination(
current_page=0,
total_items=0,
total_pages=0,
page_size=10,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_creators")
mock_db_call.return_value = mocked_value
response = client.get("/creators")
assert response.status_code == 200
data = backend.server.v2.store.model.CreatorsResponse.model_validate(
response.json()
)
assert data.pagination.total_pages == 0
assert data.creators == []
mock_db_call.assert_called_once_with(
featured=False, search_query=None, sorted_by=None, page=1, page_size=20
)
def test_get_creators_pagination(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.CreatorsResponse(
creators=[
backend.server.v2.store.model.Creator(
name=f"Creator {i}",
username=f"creator{i}",
description=f"Creator {i} description",
avatar_url=f"avatar{i}.jpg",
num_agents=1,
agent_rating=4.5,
agent_runs=100,
)
for i in range(5)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=2,
total_items=15,
total_pages=3,
page_size=5,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_creators")
mock_db_call.return_value = mocked_value
response = client.get("/creators?page=2&page_size=5")
assert response.status_code == 200
data = backend.server.v2.store.model.CreatorsResponse.model_validate(
response.json()
)
assert len(data.creators) == 5
assert data.pagination.current_page == 2
assert data.pagination.page_size == 5
mock_db_call.assert_called_once_with(
featured=False, search_query=None, sorted_by=None, page=2, page_size=5
)
def test_get_creators_malformed_request(mocker: pytest_mock.MockFixture):
# Test with invalid page number
response = client.get("/creators?page=-1")
assert response.status_code == 422
# Test with invalid page size
response = client.get("/creators?page_size=0")
assert response.status_code == 422
# Test with non-numeric values
response = client.get("/creators?page=abc&page_size=def")
assert response.status_code == 422
# Verify no DB calls were made
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_creators")
mock_db_call.assert_not_called()
def test_get_creator_details(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.CreatorDetails(
name="Test User",
username="creator1",
description="Test creator description",
links=["link1.com", "link2.com"],
avatar_url="avatar.jpg",
agent_rating=4.8,
agent_runs=1000,
top_categories=["category1", "category2"],
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_creator_details")
mock_db_call.return_value = mocked_value
response = client.get("/creator/creator1")
assert response.status_code == 200
data = backend.server.v2.store.model.CreatorDetails.model_validate(response.json())
assert data.username == "creator1"
assert data.name == "Test User"
mock_db_call.assert_called_once_with(username="creator1")
def test_get_submissions_success(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=[
backend.server.v2.store.model.StoreSubmission(
name="Test Agent",
description="Test agent description",
image_urls=["test.jpg"],
date_submitted=datetime.datetime.now(),
status=prisma.enums.SubmissionStatus.APPROVED,
runs=50,
rating=4.2,
agent_id="test-agent-id",
agent_version=1,
sub_heading="Test agent subheading",
slug="test-agent",
)
],
pagination=backend.server.v2.store.model.Pagination(
current_page=1,
total_items=1,
total_pages=1,
page_size=20,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_submissions")
mock_db_call.return_value = mocked_value
response = client.get("/submissions")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreSubmissionsResponse.model_validate(
response.json()
)
assert len(data.submissions) == 1
assert data.submissions[0].name == "Test Agent"
assert data.pagination.current_page == 1
mock_db_call.assert_called_once_with(user_id="test-user-id", page=1, page_size=20)
def test_get_submissions_pagination(mocker: pytest_mock.MockFixture):
mocked_value = backend.server.v2.store.model.StoreSubmissionsResponse(
submissions=[],
pagination=backend.server.v2.store.model.Pagination(
current_page=2,
total_items=10,
total_pages=2,
page_size=5,
),
)
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_submissions")
mock_db_call.return_value = mocked_value
response = client.get("/submissions?page=2&page_size=5")
assert response.status_code == 200
data = backend.server.v2.store.model.StoreSubmissionsResponse.model_validate(
response.json()
)
assert data.pagination.current_page == 2
assert data.pagination.page_size == 5
mock_db_call.assert_called_once_with(user_id="test-user-id", page=2, page_size=5)
def test_get_submissions_malformed_request(mocker: pytest_mock.MockFixture):
# Test with invalid page number
response = client.get("/submissions?page=-1")
assert response.status_code == 422
# Test with invalid page size
response = client.get("/submissions?page_size=0")
assert response.status_code == 422
# Test with non-numeric values
response = client.get("/submissions?page=abc&page_size=def")
assert response.status_code == 422
# Verify no DB calls were made
mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_submissions")
mock_db_call.assert_not_called()

View File

@ -148,6 +148,16 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
"This value is then used to generate redirect URLs for OAuth flows.",
)
media_gcs_bucket_name: str = Field(
default="",
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

@ -1,20 +1,31 @@
version: "3"
services:
postgres-test:
image: ankane/pgvector:latest
environment:
- POSTGRES_USER=agpt_user
- POSTGRES_PASSWORD=pass123
- POSTGRES_DB=agpt_local
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASS}
- POSTGRES_DB=${DB_NAME}
healthcheck:
test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB
interval: 10s
timeout: 5s
retries: 5
ports:
- "5433:5432"
- "${DB_PORT}:5432"
networks:
- app-network-test
redis-test:
image: redis:latest
command: redis-server --requirepass password
ports:
- "6379:6379"
networks:
- app-network-test
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
app-network-test:

View File

@ -0,0 +1,228 @@
-- CreateEnum
CREATE TYPE "SubmissionStatus" AS ENUM ('DAFT', 'PENDING', 'APPROVED', 'REJECTED');
-- AlterTable
ALTER TABLE "AgentGraphExecution" ADD COLUMN "agentPresetId" TEXT;
-- AlterTable
ALTER TABLE "AgentNodeExecutionInputOutput" ADD COLUMN "agentPresetId" TEXT;
-- AlterTable
ALTER TABLE "AnalyticsMetrics" ALTER COLUMN "id" DROP DEFAULT;
-- AlterTable
ALTER TABLE "CreditTransaction" RENAME CONSTRAINT "UserBlockCredit_pkey" TO "CreditTransaction_pkey";
-- CreateTable
CREATE TABLE "AgentPreset" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"userId" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"agentVersion" INTEGER NOT NULL,
CONSTRAINT "AgentPreset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserAgent" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"agentVersion" INTEGER NOT NULL,
"agentPresetId" TEXT,
"isFavorite" BOOLEAN NOT NULL DEFAULT false,
"isCreatedByUser" BOOLEAN NOT NULL DEFAULT false,
"isArchived" BOOLEAN NOT NULL DEFAULT false,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "UserAgent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Profile" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT,
"name" TEXT NOT NULL,
"username" TEXT NOT NULL,
"description" TEXT NOT NULL,
"links" TEXT[],
"avatarUrl" TEXT,
CONSTRAINT "Profile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StoreListing" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
"isApproved" BOOLEAN NOT NULL DEFAULT false,
"agentId" TEXT NOT NULL,
"agentVersion" INTEGER NOT NULL,
"owningUserId" TEXT NOT NULL,
CONSTRAINT "StoreListing_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StoreListingVersion" (
"id" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"agentId" TEXT NOT NULL,
"agentVersion" INTEGER NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"subHeading" TEXT NOT NULL,
"videoUrl" TEXT,
"imageUrls" TEXT[],
"description" TEXT NOT NULL,
"categories" TEXT[],
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
"isAvailable" BOOLEAN NOT NULL DEFAULT true,
"isApproved" BOOLEAN NOT NULL DEFAULT false,
"storeListingId" TEXT,
CONSTRAINT "StoreListingVersion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StoreListingReview" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"storeListingVersionId" TEXT NOT NULL,
"reviewByUserId" TEXT NOT NULL,
"score" INTEGER NOT NULL,
"comments" TEXT,
CONSTRAINT "StoreListingReview_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StoreListingSubmission" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"storeListingId" TEXT NOT NULL,
"storeListingVersionId" TEXT NOT NULL,
"reviewerId" TEXT NOT NULL,
"Status" "SubmissionStatus" NOT NULL DEFAULT 'PENDING',
"reviewComments" TEXT,
CONSTRAINT "StoreListingSubmission_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AgentPreset_userId_idx" ON "AgentPreset"("userId");
-- CreateIndex
CREATE INDEX "UserAgent_userId_idx" ON "UserAgent"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Profile_username_key" ON "Profile"("username");
-- CreateIndex
CREATE INDEX "Profile_username_idx" ON "Profile"("username");
-- CreateIndex
CREATE INDEX "Profile_userId_idx" ON "Profile"("userId");
-- CreateIndex
CREATE INDEX "StoreListing_isApproved_idx" ON "StoreListing"("isApproved");
-- CreateIndex
CREATE INDEX "StoreListing_agentId_idx" ON "StoreListing"("agentId");
-- CreateIndex
CREATE INDEX "StoreListing_owningUserId_idx" ON "StoreListing"("owningUserId");
-- CreateIndex
CREATE INDEX "StoreListingVersion_agentId_agentVersion_isApproved_idx" ON "StoreListingVersion"("agentId", "agentVersion", "isApproved");
-- CreateIndex
CREATE UNIQUE INDEX "StoreListingVersion_agentId_agentVersion_key" ON "StoreListingVersion"("agentId", "agentVersion");
-- CreateIndex
CREATE INDEX "StoreListingReview_storeListingVersionId_idx" ON "StoreListingReview"("storeListingVersionId");
-- CreateIndex
CREATE UNIQUE INDEX "StoreListingReview_storeListingVersionId_reviewByUserId_key" ON "StoreListingReview"("storeListingVersionId", "reviewByUserId");
-- CreateIndex
CREATE INDEX "StoreListingSubmission_storeListingId_idx" ON "StoreListingSubmission"("storeListingId");
-- CreateIndex
CREATE INDEX "StoreListingSubmission_Status_idx" ON "StoreListingSubmission"("Status");
-- RenameForeignKey
ALTER TABLE "CreditTransaction" RENAME CONSTRAINT "UserBlockCredit_blockId_fkey" TO "CreditTransaction_blockId_fkey";
-- RenameForeignKey
ALTER TABLE "CreditTransaction" RENAME CONSTRAINT "UserBlockCredit_userId_fkey" TO "CreditTransaction_userId_fkey";
-- AddForeignKey
ALTER TABLE "AgentPreset" ADD CONSTRAINT "AgentPreset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentPreset" ADD CONSTRAINT "AgentPreset_agentId_agentVersion_fkey" FOREIGN KEY ("agentId", "agentVersion") REFERENCES "AgentGraph"("id", "version") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserAgent" ADD CONSTRAINT "UserAgent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserAgent" ADD CONSTRAINT "UserAgent_agentId_agentVersion_fkey" FOREIGN KEY ("agentId", "agentVersion") REFERENCES "AgentGraph"("id", "version") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserAgent" ADD CONSTRAINT "UserAgent_agentPresetId_fkey" FOREIGN KEY ("agentPresetId") REFERENCES "AgentPreset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentGraphExecution" ADD CONSTRAINT "AgentGraphExecution_agentPresetId_fkey" FOREIGN KEY ("agentPresetId") REFERENCES "AgentPreset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentNodeExecutionInputOutput" ADD CONSTRAINT "AgentNodeExecutionInputOutput_agentPresetId_fkey" FOREIGN KEY ("agentPresetId") REFERENCES "AgentPreset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListing" ADD CONSTRAINT "StoreListing_agentId_agentVersion_fkey" FOREIGN KEY ("agentId", "agentVersion") REFERENCES "AgentGraph"("id", "version") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListing" ADD CONSTRAINT "StoreListing_owningUserId_fkey" FOREIGN KEY ("owningUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingVersion" ADD CONSTRAINT "StoreListingVersion_agentId_agentVersion_fkey" FOREIGN KEY ("agentId", "agentVersion") REFERENCES "AgentGraph"("id", "version") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingVersion" ADD CONSTRAINT "StoreListingVersion_storeListingId_fkey" FOREIGN KEY ("storeListingId") REFERENCES "StoreListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingReview" ADD CONSTRAINT "StoreListingReview_storeListingVersionId_fkey" FOREIGN KEY ("storeListingVersionId") REFERENCES "StoreListingVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingReview" ADD CONSTRAINT "StoreListingReview_reviewByUserId_fkey" FOREIGN KEY ("reviewByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingSubmission" ADD CONSTRAINT "StoreListingSubmission_storeListingId_fkey" FOREIGN KEY ("storeListingId") REFERENCES "StoreListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingSubmission" ADD CONSTRAINT "StoreListingSubmission_storeListingVersionId_fkey" FOREIGN KEY ("storeListingVersionId") REFERENCES "StoreListingVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StoreListingSubmission" ADD CONSTRAINT "StoreListingSubmission_reviewerId_fkey" FOREIGN KEY ("reviewerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- RenameIndex
ALTER INDEX "UserBlockCredit_userId_createdAt_idx" RENAME TO "CreditTransaction_userId_createdAt_idx";

View File

@ -0,0 +1,118 @@
BEGIN;
CREATE VIEW "StoreAgent" AS
WITH ReviewStats AS (
SELECT sl."id" AS "storeListingId",
COUNT(sr.id) AS review_count,
AVG(CAST(sr.score AS DECIMAL)) AS avg_rating
FROM "StoreListing" sl
JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl."id"
JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id
WHERE sl."isDeleted" = FALSE
GROUP BY sl."id"
),
AgentRuns AS (
SELECT "agentGraphId", COUNT(*) AS run_count
FROM "AgentGraphExecution"
GROUP BY "agentGraphId"
)
SELECT
sl.id AS listing_id,
slv.id AS "storeListingVersionId",
slv."createdAt" AS updated_at,
slv.slug,
a.name AS agent_name,
slv."videoUrl" AS agent_video,
COALESCE(slv."imageUrls", ARRAY[]::TEXT[]) AS agent_image,
slv."isFeatured" AS featured,
p.username AS creator_username,
p."avatarUrl" AS creator_avatar,
slv."subHeading" AS sub_heading,
slv.description,
slv.categories,
COALESCE(ar.run_count, 0) AS runs,
CAST(COALESCE(rs.avg_rating, 0.0) AS DOUBLE PRECISION) AS rating,
ARRAY_AGG(DISTINCT CAST(slv.version AS TEXT)) AS versions
FROM "StoreListing" sl
JOIN "AgentGraph" a ON sl."agentId" = a.id AND sl."agentVersion" = a."version"
LEFT JOIN "Profile" p ON sl."owningUserId" = p."userId"
LEFT JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id
LEFT JOIN ReviewStats rs ON sl.id = rs."storeListingId"
LEFT JOIN AgentRuns ar ON a.id = ar."agentGraphId"
WHERE sl."isDeleted" = FALSE
AND sl."isApproved" = TRUE
GROUP BY sl.id, slv.id, slv.slug, slv."createdAt", a.name, slv."videoUrl", slv."imageUrls", slv."isFeatured",
p.username, p."avatarUrl", slv."subHeading", slv.description, slv.categories,
ar.run_count, rs.avg_rating;
CREATE VIEW "Creator" AS
WITH AgentStats AS (
SELECT
p.username,
COUNT(DISTINCT sl.id) as num_agents,
AVG(CAST(COALESCE(sr.score, 0) AS DECIMAL)) as agent_rating,
SUM(COALESCE(age.run_count, 0)) as agent_runs
FROM "Profile" p
LEFT JOIN "StoreListing" sl ON sl."owningUserId" = p."userId"
LEFT JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id
LEFT JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id
LEFT JOIN (
SELECT "agentGraphId", COUNT(*) as run_count
FROM "AgentGraphExecution"
GROUP BY "agentGraphId"
) age ON age."agentGraphId" = sl."agentId"
WHERE sl."isDeleted" = FALSE AND sl."isApproved" = TRUE
GROUP BY p.username
)
SELECT
p.username,
p.name,
p."avatarUrl" as avatar_url,
p.description,
ARRAY_AGG(DISTINCT c) FILTER (WHERE c IS NOT NULL) as top_categories,
p.links,
COALESCE(ast.num_agents, 0) as num_agents,
COALESCE(ast.agent_rating, 0.0) as agent_rating,
COALESCE(ast.agent_runs, 0) as agent_runs
FROM "Profile" p
LEFT JOIN AgentStats ast ON ast.username = p.username
LEFT JOIN LATERAL (
SELECT UNNEST(slv.categories) as c
FROM "StoreListing" sl
JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id
WHERE sl."owningUserId" = p."userId"
AND sl."isDeleted" = FALSE
AND sl."isApproved" = TRUE
) cats ON true
GROUP BY p.username, p.name, p."avatarUrl", p.description, p.links,
ast.num_agents, ast.agent_rating, ast.agent_runs;
CREATE VIEW "StoreSubmission" AS
SELECT
sl.id as listing_id,
sl."owningUserId" as user_id,
slv."agentId" as agent_id,
slv."version" as agent_version,
slv.slug,
slv.name,
slv."subHeading" as sub_heading,
slv.description,
slv."imageUrls" as image_urls,
slv."createdAt" as date_submitted,
COALESCE(sls."Status", 'PENDING') as status,
COALESCE(ar.run_count, 0) as runs,
CAST(COALESCE(AVG(CAST(sr.score AS DECIMAL)), 0.0) AS DOUBLE PRECISION) as rating
FROM "StoreListing" sl
JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id
LEFT JOIN "StoreListingSubmission" sls ON sls."storeListingId" = sl.id
LEFT JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id
LEFT JOIN (
SELECT "agentGraphId", COUNT(*) as run_count
FROM "AgentGraphExecution"
GROUP BY "agentGraphId"
) ar ON ar."agentGraphId" = slv."agentId"
WHERE sl."isDeleted" = FALSE
GROUP BY sl.id, sl."owningUserId", slv."agentId", slv."version", slv.slug, slv.name, slv."subHeading",
slv.description, slv."imageUrls", slv."createdAt", sls."Status", ar.run_count;
COMMIT;

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ packages = [{ include = "backend" }]
[tool.poetry.dependencies]
python = "^3.10"
python = ">=3.10,<3.13"
aio-pika = "^9.5.0"
anthropic = "^0.39.0"
apscheduler = "^3.11.0"
@ -49,8 +49,10 @@ googlemaps = "^4.10.0"
replicate = "^1.0.4"
pinecone = "^5.3.1"
cryptography = "^43.0.3"
python-multipart = "^0.0.19"
sqlalchemy = "^2.0.36"
psycopg2-binary = "^2.9.10"
google-cloud-storage = "^2.18.2"
launchdarkly-server-sdk = "^9.8.0"
[tool.poetry.group.dev.dependencies]
poethepoet = "^0.31.0"
@ -62,6 +64,8 @@ pyright = "^1.1.389"
isort = "^5.13.2"
black = "^24.10.0"
aiohappyeyeballs = "^2.4.3"
pytest-mock = "^3.14.0"
faker = "^30.8.2"
[build-system]
requires = ["poetry-core"]

View File

@ -16,9 +16,9 @@ def wait_for_postgres(max_retries=5, delay=5):
"postgres-test",
"pg_isready",
"-U",
"agpt_user",
"postgres",
"-d",
"agpt_local",
"postgres",
],
check=True,
capture_output=True,

View File

@ -8,6 +8,7 @@ generator client {
provider = "prisma-client-py"
recursive_type_depth = 5
interface = "asyncio"
previewFeatures = ["views"]
}
// User model to mirror Auth provider users
@ -24,11 +25,19 @@ model User {
// Relations
AgentGraphs AgentGraph[]
AgentGraphExecutions AgentGraphExecution[]
IntegrationWebhooks IntegrationWebhook[]
AnalyticsDetails AnalyticsDetails[]
AnalyticsMetrics AnalyticsMetrics[]
CreditTransaction CreditTransaction[]
APIKeys APIKey[]
AgentPreset AgentPreset[]
UserAgent UserAgent[]
Profile Profile[]
StoreListing StoreListing[]
StoreListingReview StoreListingReview[]
StoreListingSubmission StoreListingSubmission[]
APIKeys APIKey[]
IntegrationWebhooks IntegrationWebhook[]
@@index([id])
@@index([email])
@ -48,15 +57,89 @@ model AgentGraph {
// Link to User model
userId String
// FIX: Do not cascade delete the agent when the user is deleted
// This allows us to delete user data with deleting the agent which maybe in use by other users
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
AgentNodes AgentNode[]
AgentGraphExecution AgentGraphExecution[]
AgentPreset AgentPreset[]
UserAgent UserAgent[]
StoreListing StoreListing[]
StoreListingVersion StoreListingVersion?
@@id(name: "graphVersionId", [id, version])
@@index([userId, isActive])
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
//////////////// USER SPECIFIC DATA ////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
// An AgentPrest is an Agent + User Configuration of that agent.
// For example, if someone has created a weather agent and they want to set it up to
// Inform them of extreme weather warnings in Texas, the agent with the configuration to set it to
// monitor texas, along with the cron setup or webhook tiggers, is an AgentPreset
model AgentPreset {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
name String
description String
// For agents that can be triggered by webhooks or cronjob
// This bool allows us to disable a configured agent without deleting it
isActive Boolean @default(true)
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
agentId String
agentVersion Int
Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version], onDelete: Cascade)
InputPresets AgentNodeExecutionInputOutput[] @relation("AgentPresetsInputData")
UserAgents UserAgent[]
AgentExecution AgentGraphExecution[]
@@index([userId])
}
// For the library page
// It is a user controlled list of agents, that they will see in there library
model UserAgent {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
agentId String
agentVersion Int
Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version])
agentPresetId String?
AgentPreset AgentPreset? @relation(fields: [agentPresetId], references: [id])
isFavorite Boolean @default(false)
isCreatedByUser Boolean @default(false)
isArchived Boolean @default(false)
isDeleted Boolean @default(false)
@@index([userId])
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
//////// AGENT DEFINITION AND EXECUTION TABLES ////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
// This model describes a single node in the Agent Graph/Flow (Multi Agent System).
model AgentNode {
id String @id @default(uuid())
@ -155,7 +238,9 @@ model AgentGraphExecution {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stats String? // JSON serialized object
stats String? // JSON serialized object
AgentPreset AgentPreset? @relation(fields: [agentPresetId], references: [id])
agentPresetId String?
@@index([agentGraphId, agentGraphVersion])
@@index([userId])
@ -202,6 +287,9 @@ model AgentNodeExecutionInputOutput {
referencedByOutputExecId String?
ReferencedByOutputExec AgentNodeExecution? @relation("AgentNodeExecutionOutput", fields: [referencedByOutputExecId], references: [id], onDelete: Cascade)
agentPresetId String?
AgentPreset AgentPreset? @relation("AgentPresetsInputData", fields: [agentPresetId], references: [id])
// Input and Output pin names are unique for each AgentNodeExecution.
@@unique([referencedByInputExecId, referencedByOutputExecId, name])
@@index([referencedByOutputExecId])
@ -256,8 +344,13 @@ model AnalyticsDetails {
@@index([type])
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////// METRICS TRACKING TABLES ////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
model AnalyticsMetrics {
id String @id @default(dbgenerated("gen_random_uuid()"))
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -281,6 +374,11 @@ enum CreditTransactionType {
USAGE
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
//////// ACCOUNTING AND CREDIT SYSTEM TABLES //////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
model CreditTransaction {
transactionKey String @default(uuid())
createdAt DateTime @default(now())
@ -301,6 +399,205 @@ model CreditTransaction {
@@index([userId, createdAt])
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////// Store TABLES ///////////////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
model Profile {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
// Only 1 of user or group can be set.
// The user this profile belongs to, if any.
userId String?
User User? @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
username String @unique
description String
links String[]
avatarUrl String?
@@index([username])
@@index([userId])
}
view Creator {
username String @unique
name String
avatar_url String
description String
top_categories String[]
links String[]
num_agents Int
agent_rating Float
agent_runs Int
}
view StoreAgent {
listing_id String @id
storeListingVersionId String
updated_at DateTime
slug String
agent_name String
agent_video String?
agent_image String[]
featured Boolean @default(false)
creator_username String
creator_avatar String
sub_heading String
description String
categories String[]
runs Int
rating Float
versions String[]
@@unique([creator_username, slug])
@@index([creator_username])
@@index([featured])
@@index([categories])
@@index([storeListingVersionId])
}
view StoreSubmission {
listing_id String @id
user_id String
slug String
name String
sub_heading String
description String
image_urls String[]
date_submitted DateTime
status SubmissionStatus
runs Int
rating Float
agent_id String
agent_version Int
@@index([user_id])
}
model StoreListing {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
isDeleted Boolean @default(false)
// Not needed but makes lookups faster
isApproved Boolean @default(false)
// The agent link here is only so we can do lookup on agentId, for the listing the StoreListingVersion is used.
agentId String
agentVersion Int
Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version], onDelete: Cascade)
owningUserId String
OwningUser User @relation(fields: [owningUserId], references: [id])
StoreListingVersions StoreListingVersion[]
StoreListingSubmission StoreListingSubmission[]
@@index([isApproved])
@@index([agentId])
@@index([owningUserId])
}
model StoreListingVersion {
id String @id @default(uuid())
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
// The agent and version to be listed on the store
agentId String
agentVersion Int
Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version])
// The detials for this version of the agent, this allows the author to update the details of the agent,
// But still allow using old versions of the agent with there original details.
// TODO: Create a database view that shows only the latest version of each store listing.
slug String
name String
subHeading String
videoUrl String?
imageUrls String[]
description String
categories String[]
isFeatured Boolean @default(false)
isDeleted Boolean @default(false)
// Old versions can be made unavailable by the author if desired
isAvailable Boolean @default(true)
// Not needed but makes lookups faster
isApproved Boolean @default(false)
StoreListing StoreListing? @relation(fields: [storeListingId], references: [id], onDelete: Cascade)
storeListingId String?
StoreListingSubmission StoreListingSubmission[]
// Reviews are on a specific version, but then aggregated up to the listing.
// This allows us to provide a review filter to current version of the agent.
StoreListingReview StoreListingReview[]
@@unique([agentId, agentVersion])
@@index([agentId, agentVersion, isApproved])
}
model StoreListingReview {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
storeListingVersionId String
StoreListingVersion StoreListingVersion @relation(fields: [storeListingVersionId], references: [id], onDelete: Cascade)
reviewByUserId String
ReviewByUser User @relation(fields: [reviewByUserId], references: [id])
score Int
comments String?
@@unique([storeListingVersionId, reviewByUserId])
@@index([storeListingVersionId])
}
enum SubmissionStatus {
DAFT
PENDING
APPROVED
REJECTED
}
model StoreListingSubmission {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
storeListingId String
StoreListing StoreListing @relation(fields: [storeListingId], references: [id], onDelete: Cascade)
storeListingVersionId String
StoreListingVersion StoreListingVersion @relation(fields: [storeListingVersionId], references: [id], onDelete: Cascade)
reviewerId String
Reviewer User @relation(fields: [reviewerId], references: [id])
Status SubmissionStatus @default(PENDING)
reviewComments String?
@@index([storeListingId])
@@index([Status])
}
enum APIKeyPermission {
EXECUTE_GRAPH // Can execute agent graphs
READ_GRAPH // Can get graph versions and details
@ -338,4 +635,4 @@ enum APIKeyStatus {
ACTIVE
REVOKED
SUSPENDED
}
}

View File

@ -0,0 +1,436 @@
import asyncio
import random
from datetime import datetime
import prisma.enums
from faker import Faker
from prisma import Prisma
faker = Faker()
# Constants for data generation limits
# Base entities
NUM_USERS = 100 # Creates 100 user records
NUM_AGENT_BLOCKS = 100 # Creates 100 agent block templates
# Per-user entities
MIN_GRAPHS_PER_USER = 1 # Each user will have between 1-5 graphs
MAX_GRAPHS_PER_USER = 5 # Total graphs: 500-2500 (NUM_USERS * MIN/MAX_GRAPHS)
# Per-graph entities
MIN_NODES_PER_GRAPH = 2 # Each graph will have between 2-5 nodes
MAX_NODES_PER_GRAPH = (
5 # Total nodes: 1000-2500 (GRAPHS_PER_USER * NUM_USERS * MIN/MAX_NODES)
)
# Additional per-user entities
MIN_PRESETS_PER_USER = 1 # Each user will have between 1-2 presets
MAX_PRESETS_PER_USER = 5 # Total presets: 500-2500 (NUM_USERS * MIN/MAX_PRESETS)
MIN_AGENTS_PER_USER = 1 # Each user will have between 1-2 agents
MAX_AGENTS_PER_USER = 10 # Total agents: 500-5000 (NUM_USERS * MIN/MAX_AGENTS)
# Execution and review records
MIN_EXECUTIONS_PER_GRAPH = 1 # Each graph will have between 1-5 execution records
MAX_EXECUTIONS_PER_GRAPH = (
20 # Total executions: 1000-5000 (TOTAL_GRAPHS * MIN/MAX_EXECUTIONS)
)
MIN_REVIEWS_PER_VERSION = 1 # Each version will have between 1-3 reviews
MAX_REVIEWS_PER_VERSION = 5 # Total reviews depends on number of versions created
def get_image():
url = faker.image_url()
while "placekitten.com" in url:
url = faker.image_url()
return url
async def main():
db = Prisma()
await db.connect()
# Insert Users
print(f"Inserting {NUM_USERS} users")
users = []
for _ in range(NUM_USERS):
user = await db.user.create(
data={
"id": str(faker.uuid4()),
"email": faker.unique.email(),
"name": faker.name(),
"metadata": prisma.Json({}),
"integrations": "",
}
)
users.append(user)
# Insert AgentBlocks
agent_blocks = []
print(f"Inserting {NUM_AGENT_BLOCKS} agent blocks")
for _ in range(NUM_AGENT_BLOCKS):
block = await db.agentblock.create(
data={
"name": f"{faker.word()}_{str(faker.uuid4())[:8]}",
"inputSchema": "{}",
"outputSchema": "{}",
}
)
agent_blocks.append(block)
# Insert AgentGraphs
agent_graphs = []
print(f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER} agent graphs")
for user in users:
for _ in range(
random.randint(MIN_GRAPHS_PER_USER, MAX_GRAPHS_PER_USER)
): # Adjust the range to create more graphs per user if desired
graph = await db.agentgraph.create(
data={
"name": faker.sentence(nb_words=3),
"description": faker.text(max_nb_chars=200),
"userId": user.id,
"isActive": True,
"isTemplate": False,
}
)
agent_graphs.append(graph)
# Insert AgentNodes
agent_nodes = []
print(
f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER * MAX_NODES_PER_GRAPH} agent nodes"
)
for graph in agent_graphs:
num_nodes = random.randint(MIN_NODES_PER_GRAPH, MAX_NODES_PER_GRAPH)
for _ in range(num_nodes): # Create 5 AgentNodes per graph
block = random.choice(agent_blocks)
node = await db.agentnode.create(
data={
"agentBlockId": block.id,
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"constantInput": "{}",
"metadata": "{}",
}
)
agent_nodes.append(node)
# Insert AgentPresets
agent_presets = []
print(f"Inserting {NUM_USERS * MAX_PRESETS_PER_USER} agent presets")
for user in users:
num_presets = random.randint(MIN_PRESETS_PER_USER, MAX_PRESETS_PER_USER)
for _ in range(num_presets): # Create 1 AgentPreset per user
graph = random.choice(agent_graphs)
preset = await db.agentpreset.create(
data={
"name": faker.sentence(nb_words=3),
"description": faker.text(max_nb_chars=200),
"userId": user.id,
"agentId": graph.id,
"agentVersion": graph.version,
"isActive": True,
}
)
agent_presets.append(preset)
# Insert UserAgents
user_agents = []
print(f"Inserting {NUM_USERS * MAX_AGENTS_PER_USER} user agents")
for user in users:
num_agents = random.randint(MIN_AGENTS_PER_USER, MAX_AGENTS_PER_USER)
for _ in range(num_agents): # Create 1 UserAgent per user
graph = random.choice(agent_graphs)
preset = random.choice(agent_presets)
user_agent = await db.useragent.create(
data={
"userId": user.id,
"agentId": graph.id,
"agentVersion": graph.version,
"agentPresetId": preset.id,
"isFavorite": random.choice([True, False]),
"isCreatedByUser": random.choice([True, False]),
"isArchived": random.choice([True, False]),
"isDeleted": random.choice([True, False]),
}
)
user_agents.append(user_agent)
# Insert AgentGraphExecutions
# Insert AgentGraphExecutions
agent_graph_executions = []
print(
f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER * MAX_EXECUTIONS_PER_GRAPH} agent graph executions"
)
graph_execution_data = []
for graph in agent_graphs:
user = random.choice(users)
num_executions = random.randint(
MIN_EXECUTIONS_PER_GRAPH, MAX_EXECUTIONS_PER_GRAPH
)
for _ in range(num_executions):
matching_presets = [p for p in agent_presets if p.agentId == graph.id]
preset = (
random.choice(matching_presets)
if matching_presets and random.random() < 0.5
else None
)
graph_execution_data.append(
{
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"userId": user.id,
"executionStatus": prisma.enums.AgentExecutionStatus.COMPLETED,
"startedAt": faker.date_time_this_year(),
"agentPresetId": preset.id if preset else None,
}
)
agent_graph_executions = await db.agentgraphexecution.create_many(
data=graph_execution_data
)
# Need to fetch the created records since create_many doesn't return them
agent_graph_executions = await db.agentgraphexecution.find_many()
# Insert AgentNodeExecutions
print(
f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER * MAX_EXECUTIONS_PER_GRAPH} agent node executions"
)
node_execution_data = []
for execution in agent_graph_executions:
nodes = [
node for node in agent_nodes if node.agentGraphId == execution.agentGraphId
]
for node in nodes:
node_execution_data.append(
{
"agentGraphExecutionId": execution.id,
"agentNodeId": node.id,
"executionStatus": prisma.enums.AgentExecutionStatus.COMPLETED,
"addedTime": datetime.now(),
}
)
agent_node_executions = await db.agentnodeexecution.create_many(
data=node_execution_data
)
# Need to fetch the created records since create_many doesn't return them
agent_node_executions = await db.agentnodeexecution.find_many()
# Insert AgentNodeExecutionInputOutput
print(
f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER * MAX_EXECUTIONS_PER_GRAPH} agent node execution input/outputs"
)
input_output_data = []
for node_execution in agent_node_executions:
# Input data
input_output_data.append(
{
"name": "input1",
"data": "{}",
"time": datetime.now(),
"referencedByInputExecId": node_execution.id,
}
)
# Output data
input_output_data.append(
{
"name": "output1",
"data": "{}",
"time": datetime.now(),
"referencedByOutputExecId": node_execution.id,
}
)
await db.agentnodeexecutioninputoutput.create_many(data=input_output_data)
# Insert AgentNodeLinks
print(f"Inserting {NUM_USERS * MAX_GRAPHS_PER_USER} agent node links")
for graph in agent_graphs:
nodes = [node for node in agent_nodes if node.agentGraphId == graph.id]
if len(nodes) >= 2:
source_node = nodes[0]
sink_node = nodes[1]
await db.agentnodelink.create(
data={
"agentNodeSourceId": source_node.id,
"sourceName": "output1",
"agentNodeSinkId": sink_node.id,
"sinkName": "input1",
"isStatic": False,
}
)
# Insert AnalyticsDetails
print(f"Inserting {NUM_USERS} analytics details")
for user in users:
for _ in range(1):
await db.analyticsdetails.create(
data={
"userId": user.id,
"type": faker.word(),
"data": prisma.Json({}),
"dataIndex": faker.word(),
}
)
# Insert AnalyticsMetrics
print(f"Inserting {NUM_USERS} analytics metrics")
for user in users:
for _ in range(1):
await db.analyticsmetrics.create(
data={
"userId": user.id,
"analyticMetric": faker.word(),
"value": random.uniform(0, 100),
"dataString": faker.word(),
}
)
# Insert CreditTransaction (formerly UserBlockCredit)
print(f"Inserting {NUM_USERS} credit transactions")
for user in users:
for _ in range(1):
block = random.choice(agent_blocks)
await db.credittransaction.create(
data={
"transactionKey": str(faker.uuid4()),
"userId": user.id,
"blockId": block.id,
"amount": random.randint(1, 100),
"type": (
prisma.enums.CreditTransactionType.TOP_UP
if random.random() < 0.5
else prisma.enums.CreditTransactionType.USAGE
),
"metadata": prisma.Json({}),
}
)
# Insert Profiles
profiles = []
print(f"Inserting {NUM_USERS} profiles")
for user in users:
profile = await db.profile.create(
data={
"userId": user.id,
"name": user.name or faker.name(),
"username": faker.unique.user_name(),
"description": faker.text(),
"links": [faker.url() for _ in range(3)],
"avatarUrl": get_image(),
}
)
profiles.append(profile)
# Insert StoreListings
store_listings = []
print(f"Inserting {NUM_USERS} store listings")
for graph in agent_graphs:
user = random.choice(users)
listing = await db.storelisting.create(
data={
"agentId": graph.id,
"agentVersion": graph.version,
"owningUserId": user.id,
"isApproved": random.choice([True, False]),
}
)
store_listings.append(listing)
# Insert StoreListingVersions
store_listing_versions = []
print(f"Inserting {NUM_USERS} store listing versions")
for listing in store_listings:
graph = [g for g in agent_graphs if g.id == listing.agentId][0]
version = await db.storelistingversion.create(
data={
"agentId": graph.id,
"agentVersion": graph.version,
"slug": faker.slug(),
"name": graph.name or faker.sentence(nb_words=3),
"subHeading": faker.sentence(),
"videoUrl": faker.url(),
"imageUrls": [get_image() for _ in range(3)],
"description": faker.text(),
"categories": [faker.word() for _ in range(3)],
"isFeatured": random.choice([True, False]),
"isAvailable": True,
"isApproved": random.choice([True, False]),
"storeListingId": listing.id,
}
)
store_listing_versions.append(version)
# Insert StoreListingReviews
print(f"Inserting {NUM_USERS * MAX_REVIEWS_PER_VERSION} store listing reviews")
for version in store_listing_versions:
# Create a copy of users list and shuffle it to avoid duplicates
available_reviewers = users.copy()
random.shuffle(available_reviewers)
# Limit number of reviews to available unique reviewers
num_reviews = min(
random.randint(MIN_REVIEWS_PER_VERSION, MAX_REVIEWS_PER_VERSION),
len(available_reviewers),
)
# Take only the first num_reviews reviewers
for reviewer in available_reviewers[:num_reviews]:
await db.storelistingreview.create(
data={
"storeListingVersionId": version.id,
"reviewByUserId": reviewer.id,
"score": random.randint(1, 5),
"comments": faker.text(),
}
)
# Insert StoreListingSubmissions
print(f"Inserting {NUM_USERS} store listing submissions")
for listing in store_listings:
version = random.choice(store_listing_versions)
reviewer = random.choice(users)
status: prisma.enums.SubmissionStatus = random.choice(
[
prisma.enums.SubmissionStatus.PENDING,
prisma.enums.SubmissionStatus.APPROVED,
prisma.enums.SubmissionStatus.REJECTED,
]
)
await db.storelistingsubmission.create(
data={
"storeListingId": listing.id,
"storeListingVersionId": version.id,
"reviewerId": reviewer.id,
"Status": status,
"reviewComments": faker.text(),
}
)
# Insert APIKeys
print(f"Inserting {NUM_USERS} api keys")
for user in users:
await db.apikey.create(
data={
"name": faker.word(),
"prefix": str(faker.uuid4())[:8],
"postfix": str(faker.uuid4())[-8:],
"key": str(faker.sha256()),
"status": prisma.enums.APIKeyStatus.ACTIVE,
"permissions": [
prisma.enums.APIKeyPermission.EXECUTE_GRAPH,
prisma.enums.APIKeyPermission.READ_GRAPH,
],
"description": faker.text(),
"userId": user.id,
}
)
await db.disconnect()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -6,6 +6,11 @@ NEXT_PUBLIC_LAUNCHDARKLY_ENABLED=false
NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID=
NEXT_PUBLIC_APP_ENV=dev
## Locale settings
NEXT_PUBLIC_DEFAULT_LOCALE=en
NEXT_PUBLIC_LOCALES=en,es
## Supabase credentials
NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000

View File

@ -45,3 +45,6 @@ node_modules/
*storybook.log
storybook-static
*.ignore.*
*.ign.*
.cursorrules

View File

@ -3,12 +3,15 @@ import type { StorybookConfig } from "@storybook/nextjs";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-a11y",
"@storybook/addon-onboarding",
"@storybook/addon-links",
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
],
features: {
experimentalRSC: true,
},
framework: {
name: "@storybook/nextjs",
options: {},

View File

@ -1,8 +1,15 @@
import type { Preview } from "@storybook/react";
import { initialize, mswLoader } from "msw-storybook-addon";
import "../src/app/globals.css";
// Initialize MSW
initialize();
const preview: Preview = {
parameters: {
nextjs: {
appDirectory: true,
},
controls: {
matchers: {
color: /(background|color)$/i,
@ -10,6 +17,7 @@ const preview: Preview = {
},
},
},
loaders: [mswLoader],
};
export default preview;

View File

@ -0,0 +1,22 @@
import type { TestRunnerConfig } from "@storybook/test-runner";
import { injectAxe, checkA11y } from "axe-playwright";
/*
* See https://storybook.js.org/docs/writing-tests/test-runner#test-hook-api
* to learn more about the test-runner hooks API.
*/
const config: TestRunnerConfig = {
async preVisit(page) {
await injectAxe(page);
},
async postVisit(page) {
await checkA11y(page, "#storybook-root", {
detailedReport: true,
detailedReportOptions: {
html: true,
},
});
},
};
export default config;

View File

@ -7,18 +7,21 @@ RUN --mount=type=cache,target=/usr/local/share/.cache yarn install --frozen-lock
# Dev stage
FROM base AS dev
ENV NODE_ENV=development
ENV HOSTNAME=0.0.0.0
COPY autogpt_platform/frontend/ .
EXPOSE 3000
CMD ["yarn", "run", "dev"]
CMD ["yarn", "run", "dev", "--hostname", "0.0.0.0"]
# Build stage for prod
FROM base AS build
COPY autogpt_platform/frontend/ .
ENV SKIP_STORYBOOK_TESTS=true
RUN yarn build
# Prod stage - based on NextJS reference Dockerfile https://github.com/vercel/next.js/blob/64271354533ed16da51be5dce85f0dbd15f17517/examples/with-docker/Dockerfile
FROM node:21-alpine AS prod
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs

View File

@ -3,16 +3,16 @@ import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ["images.unsplash.com"],
},
async redirects() {
return [
{
source: "/monitor", // FIXME: Remove after 2024-09-01
destination: "/",
permanent: false,
},
];
domains: [
"images.unsplash.com",
"ddz4ak4pa3d19.cloudfront.net",
"upload.wikimedia.org",
"storage.googleapis.com",
"picsum.photos", // for placeholder images
"dummyimage.com", // for placeholder images
"placekitten.com", // for placeholder images
],
},
output: "standalone",
// TODO: Re-enable TypeScript checks once current issues are resolved
@ -46,7 +46,7 @@ export default withSentryConfig(nextConfig, {
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: "/monitoring",
tunnelRoute: "/store",
// Hides source maps from generated client bundles
hideSourceMaps: true,

View File

@ -4,9 +4,9 @@
"private": true,
"scripts": {
"dev": "next dev",
"dev:nosentry": "export NODE_ENV=development && export DISABLE_SENTRY=true && next dev",
"dev:test": "export NODE_ENV=test && next dev",
"build": "next build",
"dev:nosentry": "NODE_ENV=development && DISABLE_SENTRY=true && next dev",
"dev:test": "NODE_ENV=test && next dev",
"build": "SKIP_STORYBOOK_TESTS=true next build",
"start": "next start",
"lint": "next lint && prettier --check .",
"format": "prettier --write .",
@ -50,13 +50,17 @@
"@tanstack/react-table": "^8.20.5",
"@xyflow/react": "^12.3.6",
"ajv": "^8.17.1",
"boring-avatars": "^1.11.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"cookie": "1.0.2",
"date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"elliptic": "6.6.1",
"elliptic": "6.6.0",
"embla-carousel-react": "^8.3.0",
"framer-motion": "^11.11.9",
"geist": "^1.3.1",
"launchdarkly-react-client-sdk": "^3.6.0",
"lucide-react": "^0.468.0",
"moment": "^2.30.1",
@ -78,24 +82,30 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.2",
"@playwright/test": "^1.49.0",
"@storybook/addon-essentials": "^8.4.5",
"@storybook/addon-interactions": "^8.4.5",
"@storybook/addon-links": "^8.4.5",
"@storybook/addon-onboarding": "^8.4.5",
"@storybook/blocks": "^8.4.5",
"@storybook/nextjs": "^8.4.5",
"@playwright/test": "^1.48.2",
"@storybook/addon-a11y": "^8.3.5",
"@storybook/addon-essentials": "^8.4.2",
"@storybook/addon-interactions": "^8.4.2",
"@storybook/addon-links": "^8.4.2",
"@storybook/addon-onboarding": "^8.4.2",
"@storybook/blocks": "^8.4.2",
"@storybook/nextjs": "^8.4.2",
"@storybook/react": "^8.3.5",
"@storybook/test": "^8.3.5",
"@storybook/test-runner": "^0.19.1",
"@types/node": "^22.9.3",
"@types/negotiator": "^0.6.3",
"@types/node": "^22.9.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-modal": "^3.16.3",
"concurrently": "^9.1.0",
"axe-playwright": "^2.0.3",
"chromatic": "^11.12.5",
"concurrently": "^9.0.1",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"eslint-plugin-storybook": "^0.11.1",
"eslint-plugin-storybook": "^0.11.0",
"msw": "^2.5.2",
"msw-storybook-addon": "^2.0.3",
"postcss": "^8",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.9",
@ -103,5 +113,10 @@
"tailwindcss": "^3.4.15",
"typescript": "^5"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"msw": {
"workerDirectory": [
"public"
]
}
}

View File

@ -30,8 +30,11 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
screenshot: "only-on-failure",
bypassCSP: true,
},
/* Maximum time one test can run for */
timeout: 60000,
/* Configure projects for major browsers */
projects: [
@ -40,31 +43,31 @@ export default defineConfig({
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
// {
// name: "firefox",
// use: { ...devices["Desktop Firefox"] },
// },
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
// /* Test against mobile viewports. */
// // {
// // name: 'Mobile Chrome',
// // use: { ...devices['Pixel 5'] },
// // },
// // {
// // name: 'Mobile Safari',
// // use: { ...devices['iPhone 12'] },
// // },
/* Test against branded browsers. */
{
name: "Microsoft Edge",
use: { ...devices["Desktop Edge"], channel: "msedge" },
},
// /* Test against branded browsers. */
// {
// name: "Microsoft Edge",
// use: { ...devices["Desktop Edge"], channel: "msedge" },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },

View File

@ -0,0 +1,307 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.6.8'
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
self.addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
// Bypass navigation requests.
if (request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId))
})
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
requestId,
isMockedResponse: IS_MOCKED_RESPONSE in response,
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
body: responseClone.body,
headers: Object.fromEntries(responseClone.headers.entries()),
},
},
[responseClone.body],
)
})()
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const requestBuffer = await request.arrayBuffer()
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: requestBuffer,
keepalive: request.keepalive,
},
},
[requestBuffer],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(
message,
[channel.port2].concat(transferrables.filter(Boolean)),
)
})
}
async function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}

View File

@ -9,8 +9,7 @@ export default function Home() {
return (
<FlowEditor
className="flow-container"
flowID={query.get("flowID") ?? query.get("templateID") ?? undefined}
template={!!query.get("templateID")}
flowID={query.get("flowID") ?? undefined}
/>
);
}

View File

@ -0,0 +1,22 @@
{
"auth": {
"signIn": "Sign In",
"email": "Email",
"password": "Password",
"submit": "Submit",
"error": "Invalid login credentials"
},
"dashboard": {
"welcome": "Welcome to your dashboard",
"stats": "Your Stats",
"recentActivity": "Recent Activity"
},
"admin": {
"title": "Admin Dashboard",
"users": "Users Management",
"settings": "System Settings"
},
"home": {
"welcome": "Welcome to the Home Page"
}
}

View File

@ -0,0 +1,22 @@
{
"auth": {
"signIn": "Iniciar Sesión",
"email": "Correo electrónico",
"password": "Contraseña",
"submit": "Enviar",
"error": "Credenciales inválidas"
},
"dashboard": {
"welcome": "Bienvenido a tu panel",
"stats": "Tus Estadísticas",
"recentActivity": "Actividad Reciente"
},
"admin": {
"title": "Panel de Administración",
"users": "Gestión de Usuarios",
"settings": "Configuración del Sistema"
},
"home": {
"welcome": "Bienvenido a la Página de Inicio"
}
}

View File

@ -2,6 +2,45 @@
@tailwind components;
@tailwind utilities;
@layer base {
.font-neue {
font-family: "PP Neue Montreal TT", sans-serif;
}
}
@layer utilities {
.w-110 {
width: 27.5rem;
}
.h-7\.5 {
height: 1.1875rem;
}
.h-18 {
height: 4.5rem;
}
.h-238 {
height: 14.875rem;
}
.top-158 {
top: 9.875rem;
}
.top-254 {
top: 15.875rem;
}
.top-284 {
top: 17.75rem;
}
.top-360 {
top: 22.5rem;
}
.left-297 {
left: 18.5625rem;
}
.left-34 {
left: 2.125rem;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;

View File

@ -0,0 +1,3 @@
export default function HealthPage() {
return <div>Yay im healthy</div>;
}

View File

@ -2,13 +2,15 @@ import React from "react";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Providers } from "@/app/providers";
import { NavBar } from "@/components/NavBar";
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 { createServerClient } from "@/lib/supabase/server";
const inter = Inter({ subsets: ["latin"] });
@ -17,23 +19,88 @@ export const metadata: Metadata = {
description: "Your one stop shop to creating AI Agents",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const supabase = createServerClient();
const {
data: { user },
} = await supabase.auth.getUser();
return (
<html lang="en">
<body className={cn("antialiased transition-colors", inter.className)}>
<Providers
initialUser={user}
attribute="class"
defaultTheme="light"
// Feel free to remove this line if you want to use the system theme by default
// enableSystem
disableTransitionOnChange
>
<div className="flex min-h-screen flex-col">
<NavBar />
<div className="flex min-h-screen flex-col items-center justify-center">
<Navbar
user={user}
isLoggedIn={!!user}
links={[
{
name: "Agent Store",
href: "/store",
},
{
name: "Library",
href: "/monitoring",
},
{
name: "Build",
href: "/build",
},
]}
menuItemGroups={[
{
items: [
{
icon: IconType.Edit,
text: "Edit profile",
href: "/store/profile",
},
],
},
{
items: [
{
icon: IconType.LayoutDashboard,
text: "Creator Dashboard",
href: "/store/dashboard",
},
{
icon: IconType.UploadCloud,
text: "Publish an agent",
},
],
},
{
items: [
{
icon: IconType.Settings,
text: "Settings",
href: "/store/settings",
},
],
},
{
items: [
{
icon: IconType.LogOut,
text: "Log out",
},
],
},
]}
/>
<main className="flex-1 p-4">{children}</main>
<TallyPopupSimple />
</div>

View File

@ -10,6 +10,30 @@ const loginFormSchema = z.object({
password: z.string().min(6).max(64),
});
export async function logout() {
return await Sentry.withServerActionInstrumentation(
"logout",
{},
async () => {
const supabase = createServerClient();
if (!supabase) {
redirect("/error");
}
const { error } = await supabase.auth.signOut();
if (error) {
console.log("Error logging out", error);
return error.message;
}
revalidatePath("/", "layout");
redirect("/login");
},
);
}
export async function login(values: z.infer<typeof loginFormSchema>) {
return await Sentry.withServerActionInstrumentation("login", {}, async () => {
const supabase = createServerClient();
@ -22,9 +46,10 @@ export async function login(values: z.infer<typeof loginFormSchema>) {
const { data, error } = await supabase.auth.signInWithPassword(values);
if (error) {
console.log("Error logging in", error);
if (error.status == 400) {
// Hence User is not present
redirect("/signup");
redirect("/login");
}
return error.message;
@ -33,8 +58,44 @@ export async function login(values: z.infer<typeof loginFormSchema>) {
if (data.session) {
await supabase.auth.setSession(data.session);
}
console.log("Logged in");
revalidatePath("/", "layout");
redirect("/");
});
}
export async function signup(values: z.infer<typeof loginFormSchema>) {
"use server";
return await Sentry.withServerActionInstrumentation(
"signup",
{},
async () => {
const supabase = createServerClient();
if (!supabase) {
redirect("/error");
}
// We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signUp(values);
if (error) {
console.log("Error signing up", error);
if (error.message.includes("P0001")) {
return "Please join our waitlist for your turn: https://agpt.co/waitlist";
}
if (error.code?.includes("user_already_exists")) {
redirect("/login");
}
return error.message;
}
if (data.session) {
await supabase.auth.setSession(data.session);
}
console.log("Signed up");
revalidatePath("/", "layout");
redirect("/store/profile");
},
);
}

View File

@ -18,7 +18,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { PasswordInput } from "@/components/PasswordInput";
import { FaGoogle, FaGithub, FaDiscord, FaSpinner } from "react-icons/fa";
import { useState } from "react";
import { useSupabase } from "@/components/SupabaseProvider";
import { useSupabase } from "@/components/providers/SupabaseProvider";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Checkbox } from "@/components/ui/checkbox";
@ -203,14 +203,34 @@ export default function LoginPage() {
className="flex w-full justify-center"
type="submit"
disabled={isLoading}
onClick={async () => {
setIsLoading(true);
const values = form.getValues();
const result = await login(values);
if (result) {
setFeedback(result);
}
setIsLoading(false);
}}
>
Log in
{isLoading ? <FaSpinner className="animate-spin" /> : "Log in"}
</Button>
<Button
className="flex w-full justify-center"
type="button"
disabled={isLoading}
onClick={async () => {
setIsLoading(true);
const values = form.getValues();
const result = await signup(values);
if (result) {
setFeedback(result);
}
setIsLoading(false);
}}
>
{isLoading ? <FaSpinner className="animate-spin" /> : "Sign up"}
</Button>
</div>
<div className="w-full text-center">
<Link href={"/signup"} className="w-fit text-xs hover:underline">
Create a new Account
</Link>
</div>
</form>
<p className="text-sm text-red-500">{feedback}</p>

View File

@ -1,41 +0,0 @@
import { Suspense } from "react";
import { notFound } from "next/navigation";
import MarketplaceAPI from "@/lib/marketplace-api";
import { AgentDetailResponse } from "@/lib/marketplace-api";
import AgentDetailContent from "@/components/marketplace/AgentDetailContent";
async function getAgentDetails(id: string): Promise<AgentDetailResponse> {
const apiUrl =
process.env.NEXT_PUBLIC_AGPT_MARKETPLACE_URL ||
"http://localhost:8015/api/v1/market";
const api = new MarketplaceAPI(apiUrl);
try {
console.log(`Fetching agent details for id: ${id}`);
const agent = await api.getAgentDetails(id);
console.log(`Agent details fetched:`, agent);
return agent;
} catch (error) {
console.error(`Error fetching agent details:`, error);
throw error;
}
}
export default async function AgentDetailPage({
params,
}: {
params: { id: string };
}) {
let agent: AgentDetailResponse;
try {
agent = await getAgentDetails(params.id);
} catch (error) {
return notFound();
}
return (
<Suspense fallback={<div>Loading...</div>}>
<AgentDetailContent agent={agent} />
</Suspense>
);
}

View File

@ -1,352 +0,0 @@
"use client";
import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import MarketplaceAPI, {
AgentResponse,
AgentWithRank,
} from "@/lib/marketplace-api";
import {
ChevronLeft,
ChevronRight,
PlusCircle,
Search,
Star,
} from "lucide-react";
// Utility Functions
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Types
type Agent = AgentResponse | AgentWithRank;
// Components
const HeroSection: React.FC = () => {
const router = useRouter();
return (
<div className="relative bg-indigo-600 py-6">
<div className="absolute inset-0 z-0">
<Image
src="https://images.unsplash.com/photo-1562408590-e32931084e23?auto=format&fit=crop&w=2070&q=80"
alt="Marketplace background"
layout="fill"
objectFit="cover"
quality={75}
priority
className="opacity-20"
/>
<div
className="absolute inset-0 bg-indigo-600 mix-blend-multiply"
aria-hidden="true"
></div>
</div>
<div className="relative mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-white sm:text-3xl lg:text-4xl">
AutoGPT Marketplace
</h1>
<p className="mt-2 max-w-3xl text-sm text-indigo-100 sm:text-base">
Discover and share proven AI Agents to supercharge your business.
</p>
</div>
<Button
onClick={() => router.push("/marketplace/submit")}
className="flex items-center bg-white text-indigo-600 hover:bg-indigo-50"
>
<PlusCircle className="mr-2 h-4 w-4" />
Submit Agent
</Button>
</div>
</div>
);
};
const SearchInput: React.FC<{
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}> = ({ value, onChange }) => (
<div className="relative mb-8">
<Input
placeholder="Search agents..."
type="text"
className="w-full rounded-full border-gray-300 py-2 pl-10 pr-4 focus:border-indigo-500 focus:ring-indigo-500"
value={value}
onChange={onChange}
/>
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 transform text-gray-400"
size={20}
/>
</div>
);
const AgentCard: React.FC<{ agent: Agent; featured?: boolean }> = ({
agent,
featured = false,
}) => {
const router = useRouter();
const handleClick = () => {
router.push(`/marketplace/${agent.id}`);
};
return (
<div
className={`flex cursor-pointer flex-col justify-between rounded-lg border p-6 transition-colors duration-200 hover:bg-gray-50 ${featured ? "border-indigo-500 shadow-md" : "border-gray-300"}`}
onClick={handleClick}
>
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="truncate text-lg font-semibold text-gray-900">
{agent.name}
</h3>
{featured && <Star className="text-indigo-500" size={20} />}
</div>
<p className="mb-4 line-clamp-2 text-sm text-gray-500">
{agent.description}
</p>
<div className="mb-2 text-xs text-gray-400">
Categories: {agent.categories?.join(", ")}
</div>
</div>
<div className="flex items-end justify-between">
<div className="text-xs text-gray-400">
Updated {new Date(agent.updatedAt).toLocaleDateString()}
</div>
<div className="text-xs text-gray-400">Downloads {agent.downloads}</div>
{"rank" in agent && (
<div className="text-xs text-indigo-600">
Rank: {agent.rank.toFixed(2)}
</div>
)}
</div>
</div>
);
};
const AgentGrid: React.FC<{
agents: Agent[];
title: string;
featured?: boolean;
}> = ({ agents, title, featured = false }) => (
<div className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-gray-900">{title}</h2>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => (
<AgentCard agent={agent} key={agent.id} featured={featured} />
))}
</div>
</div>
);
const Pagination: React.FC<{
page: number;
totalPages: number;
onPrevPage: () => void;
onNextPage: () => void;
}> = ({ page, totalPages, onPrevPage, onNextPage }) => (
<div className="mt-8 flex items-center justify-between">
<Button
onClick={onPrevPage}
disabled={page === 1}
className="flex items-center space-x-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
>
<ChevronLeft size={16} />
<span>Previous</span>
</Button>
<span className="text-sm text-gray-700">
Page {page} of {totalPages}
</span>
<Button
onClick={onNextPage}
disabled={page === totalPages}
className="flex items-center space-x-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
>
<span>Next</span>
<ChevronRight size={16} />
</Button>
</div>
);
// Main Component
const Marketplace: React.FC = () => {
const apiUrl =
process.env.NEXT_PUBLIC_AGPT_MARKETPLACE_URL ||
"http://localhost:8015/api/v1/market";
const api = useMemo(() => new MarketplaceAPI(apiUrl), [apiUrl]);
const [searchValue, setSearchValue] = useState("");
const [searchResults, setSearchResults] = useState<Agent[]>([]);
const [featuredAgents, setFeaturedAgents] = useState<Agent[]>([]);
const [topAgents, setTopAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [topAgentsPage, setTopAgentsPage] = useState(1);
const [searchPage, setSearchPage] = useState(1);
const [topAgentsTotalPages, setTopAgentsTotalPages] = useState(1);
const [searchTotalPages, setSearchTotalPages] = useState(1);
const fetchTopAgents = useCallback(
async (currentPage: number) => {
setIsLoading(true);
try {
const response = await api.getTopDownloadedAgents(currentPage, 9);
setTopAgents(response.items);
setTopAgentsTotalPages(response.total_pages);
} catch (error) {
console.error("Error fetching top agents:", error);
} finally {
setIsLoading(false);
}
},
[api],
);
const fetchFeaturedAgents = useCallback(async () => {
try {
const featured = await api.getFeaturedAgents();
setFeaturedAgents(featured.items);
} catch (error) {
console.error("Error fetching featured agents:", error);
}
}, [api]);
const searchAgents = useCallback(
async (searchTerm: string, currentPage: number) => {
setIsLoading(true);
try {
const response = await api.searchAgents(searchTerm, currentPage, 9);
const filteredAgents = response.items.filter((agent) => agent.rank > 0);
setSearchResults(filteredAgents);
setSearchTotalPages(response.total_pages);
} catch (error) {
console.error("Error searching agents:", error);
} finally {
setIsLoading(false);
}
},
[api],
);
const debouncedSearch = useMemo(
() => debounce(searchAgents, 300),
[searchAgents],
);
useEffect(() => {
if (searchValue) {
searchAgents(searchValue, searchPage);
} else {
fetchTopAgents(topAgentsPage);
}
}, [searchValue, searchPage, topAgentsPage, searchAgents, fetchTopAgents]);
useEffect(() => {
fetchFeaturedAgents();
}, [fetchFeaturedAgents]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value);
setSearchPage(1);
};
const handleNextPage = () => {
if (searchValue) {
if (searchPage < searchTotalPages) {
setSearchPage(searchPage + 1);
}
} else {
if (topAgentsPage < topAgentsTotalPages) {
setTopAgentsPage(topAgentsPage + 1);
}
}
};
const handlePrevPage = () => {
if (searchValue) {
if (searchPage > 1) {
setSearchPage(searchPage - 1);
}
} else {
if (topAgentsPage > 1) {
setTopAgentsPage(topAgentsPage - 1);
}
}
};
return (
<div className="min-h-screen bg-gray-50">
<HeroSection />
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<SearchInput value={searchValue} onChange={handleInputChange} />
{isLoading ? (
<div className="py-12 text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-b-2 border-gray-900"></div>
<p className="mt-2 text-gray-600">Loading agents...</p>
</div>
) : searchValue ? (
searchResults.length > 0 ? (
<>
<AgentGrid agents={searchResults} title="Search Results" />
<Pagination
page={searchPage}
totalPages={searchTotalPages}
onPrevPage={handlePrevPage}
onNextPage={handleNextPage}
/>
</>
) : (
<div className="py-12 text-center">
<p className="text-gray-600">
No agents found matching your search criteria.
</p>
</div>
)
) : (
<>
{featuredAgents?.length > 0 ? (
<AgentGrid
agents={featuredAgents}
title="Featured Agents"
featured={true}
/>
) : (
<div className="py-12 text-center">
<p className="text-gray-600">No Featured Agents found</p>
</div>
)}
<hr />
{topAgents?.length > 0 ? (
<AgentGrid agents={topAgents} title="Top Downloaded Agents" />
) : (
<div className="py-12 text-center">
<p className="text-gray-600">No Top Downloaded Agents found</p>
</div>
)}
<Pagination
page={topAgentsPage}
totalPages={topAgentsTotalPages}
onPrevPage={handlePrevPage}
onNextPage={handleNextPage}
/>
</>
)}
</div>
</div>
);
};
export default Marketplace;

View File

@ -1,453 +0,0 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import MarketplaceAPI from "@/lib/marketplace-api";
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from "@/components/ui/multiselect";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type FormData = {
name: string;
description: string;
author: string;
keywords: string[];
categories: string[];
agreeToTerms: boolean;
selectedAgentId: string;
};
const keywords = [
"Automation",
"AI Workflows",
"Integration",
"Task Automation",
"Data Processing",
"Workflow Management",
"Real-time Analytics",
"Custom Triggers",
"Event-driven",
"API Integration",
"Data Transformation",
"Multi-step Workflows",
"Collaboration Tools",
"Business Process Automation",
"No-code Solutions",
"AI-Powered",
"Smart Notifications",
"Data Syncing",
"User Engagement",
"Reporting Automation",
"Lead Generation",
"Customer Support Automation",
"E-commerce Automation",
"Social Media Management",
"Email Marketing Automation",
"Document Management",
"Data Enrichment",
"Performance Tracking",
"Predictive Analytics",
"Resource Allocation",
"Chatbot",
"Virtual Assistant",
"Workflow Automation",
"Social Media Manager",
"Email Optimizer",
"Content Generator",
"Data Analyzer",
"Task Scheduler",
"Customer Service Bot",
"Personalization Engine",
];
const SubmitPage: React.FC = () => {
const router = useRouter();
const {
control,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<FormData>({
defaultValues: {
selectedAgentId: "", // Initialize with an empty string
name: "",
description: "",
author: "",
keywords: [],
categories: [],
agreeToTerms: false,
},
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [userAgents, setUserAgents] = useState<
Array<{ id: string; name: string; version: number }>
>([]);
const [selectedAgentGraph, setSelectedAgentGraph] = useState<any>(null);
const selectedAgentId = watch("selectedAgentId");
useEffect(() => {
const fetchUserAgents = async () => {
const api = new AutoGPTServerAPI();
const agents = await api.listGraphs();
console.log(agents);
setUserAgents(
agents.map((agent) => ({
id: agent.id,
name: agent.name || `Agent (${agent.id})`,
version: agent.version,
})),
);
};
fetchUserAgents();
}, []);
useEffect(() => {
const fetchAgentGraph = async () => {
if (selectedAgentId) {
const api = new AutoGPTServerAPI();
const graph = await api.getGraph(selectedAgentId, undefined, true);
setSelectedAgentGraph(graph);
setValue("name", graph.name);
setValue("description", graph.description);
}
};
fetchAgentGraph();
}, [selectedAgentId, setValue]);
const onSubmit = async (data: FormData) => {
setIsSubmitting(true);
setSubmitError(null);
if (!data.agreeToTerms) {
throw new Error("You must agree to the terms of use");
}
try {
if (!selectedAgentGraph) {
throw new Error("Please select an agent");
}
const api = new MarketplaceAPI();
await api.submitAgent(
{
...selectedAgentGraph,
name: data.name,
description: data.description,
},
data.author,
data.keywords,
data.categories,
);
router.push("/marketplace?submission=success");
} catch (error) {
console.error("Submission error:", error);
setSubmitError(
error instanceof Error ? error.message : "An unknown error occurred",
);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
<h1 className="mb-6 text-3xl font-bold">Submit Your Agent</h1>
<Card className="p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<Controller
name="selectedAgentId"
control={control}
rules={{ required: "Please select an agent" }}
render={({ field }) => (
<div>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
Select Agent
</label>
<Select
onValueChange={field.onChange}
value={field.value || ""}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an agent" />
</SelectTrigger>
<SelectContent>
{userAgents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name} (v{agent.version})
</SelectItem>
))}
</SelectContent>
</Select>
{errors.selectedAgentId && (
<p className="mt-1 text-sm text-red-600">
{errors.selectedAgentId.message}
</p>
)}
</div>
)}
/>
{/* {selectedAgentGraph && (
<div className="mt-4" style={{ height: "600px" }}>
<ReactFlow
nodes={nodes}
edges={edges}
fitView
attributionPosition="bottom-left"
nodesConnectable={false}
nodesDraggable={false}
zoomOnScroll={false}
panOnScroll={false}
elementsSelectable={false}
>
<Controls showInteractive={false} />
<Background />
</ReactFlow>
</div>
)} */}
<Controller
name="name"
control={control}
rules={{ required: "Name is required" }}
render={({ field }) => (
<div>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
Agent Name
</label>
<Input
id={field.name}
placeholder="Enter your agent's name"
{...field}
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">
{errors.name.message}
</p>
)}
</div>
)}
/>
<Controller
name="description"
control={control}
rules={{ required: "Description is required" }}
render={({ field }) => (
<div>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
Description
</label>
<Textarea
id={field.name}
placeholder="Describe your agent"
{...field}
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">
{errors.description.message}
</p>
)}
</div>
)}
/>
<Controller
name="author"
control={control}
rules={{ required: "Author is required" }}
render={({ field }) => (
<div>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
Author
</label>
<Input
id={field.name}
placeholder="Your name or username"
{...field}
/>
{errors.author && (
<p className="mt-1 text-sm text-red-600">
{errors.author.message}
</p>
)}
</div>
)}
/>
<Controller
name="keywords"
control={control}
rules={{ required: "At least one keyword is required" }}
render={({ field }) => (
<div>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
Keywords
</label>
<MultiSelector
values={field.value || []}
onValuesChange={field.onChange}
>
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Add keywords" />
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
{keywords.map((keyword) => (
<MultiSelectorItem key={keyword} value={keyword}>
{keyword}
</MultiSelectorItem>
))}
{/* Add more predefined keywords as needed */}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
{errors.keywords && (
<p className="mt-1 text-sm text-red-600">
{errors.keywords.message}
</p>
)}
</div>
)}
/>
<Controller
name="categories"
control={control}
rules={{ required: "At least one category is required" }}
render={({ field }) => (
<div>
<label
htmlFor={field.name}
className="block text-sm font-medium text-gray-700"
>
Categories
</label>
<MultiSelector
values={field.value || []}
onValuesChange={field.onChange}
>
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Select categories" />
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
<MultiSelectorItem value="productivity">
Productivity
</MultiSelectorItem>
<MultiSelectorItem value="entertainment">
Entertainment
</MultiSelectorItem>
<MultiSelectorItem value="education">
Education
</MultiSelectorItem>
<MultiSelectorItem value="business">
Business
</MultiSelectorItem>
<MultiSelectorItem value="other">
Other
</MultiSelectorItem>
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
{errors.categories && (
<p className="mt-1 text-sm text-red-600">
{errors.categories.message}
</p>
)}
</div>
)}
/>
<Controller
name="agreeToTerms"
control={control}
rules={{ required: "You must agree to the terms of use" }}
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="agreeToTerms"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
htmlFor="agreeToTerms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I agree to the{" "}
<a
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
className="text-blue-500 hover:underline"
>
terms of use
</a>
</label>
</div>
)}
/>
{errors.agreeToTerms && (
<p className="mt-1 text-sm text-red-600">
{errors.agreeToTerms.message}
</p>
)}
{submitError && (
<Alert variant="destructive">
<AlertTitle>Submission Failed</AlertTitle>
<AlertDescription>{submitError}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit Agent"}
</Button>
</div>
</form>
</Card>
</div>
);
};
export default SubmitPage;

View File

@ -0,0 +1,168 @@
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import AutoGPTServerAPI, {
GraphMetaWithRuns,
ExecutionMeta,
Schedule,
} from "@/lib/autogpt-server-api";
import { Card } from "@/components/ui/card";
import { FlowRun } from "@/lib/types";
import {
AgentFlowList,
FlowInfo,
FlowRunInfo,
FlowRunsList,
FlowRunsStats,
} from "@/components/monitor";
import { SchedulesTable } from "@/components/monitor/scheduleTable";
const Monitor = () => {
const [flows, setFlows] = useState<GraphMetaWithRuns[]>([]);
const [flowRuns, setFlowRuns] = useState<FlowRun[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [selectedFlow, setSelectedFlow] = useState<GraphMetaWithRuns | null>(
null,
);
const [selectedRun, setSelectedRun] = useState<FlowRun | null>(null);
const [sortColumn, setSortColumn] = useState<keyof Schedule>("id");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const api = useMemo(() => new AutoGPTServerAPI(), []);
const fetchSchedules = useCallback(async () => {
setSchedules(await api.listSchedules());
}, [api]);
const removeSchedule = useCallback(
async (scheduleId: string) => {
const removedSchedule = await api.deleteSchedule(scheduleId);
setSchedules(schedules.filter((s) => s.id !== removedSchedule.id));
},
[schedules, api],
);
const fetchAgents = useCallback(() => {
api.listGraphsWithRuns().then((agent) => {
setFlows(agent);
const flowRuns = agent.flatMap((graph) =>
graph.executions != null
? graph.executions.map((execution) =>
flowRunFromExecutionMeta(graph, execution),
)
: [],
);
setFlowRuns(flowRuns);
});
}, [api]);
useEffect(() => {
fetchAgents();
}, [fetchAgents]);
useEffect(() => {
fetchSchedules();
}, [fetchSchedules]);
useEffect(() => {
const intervalId = setInterval(() => fetchAgents(), 5000);
return () => clearInterval(intervalId);
}, [fetchAgents, flows]);
const column1 = "md:col-span-2 xl:col-span-3 xxl:col-span-2";
const column2 = "md:col-span-3 lg:col-span-2 xl:col-span-3";
const column3 = "col-span-full xl:col-span-4 xxl:col-span-5";
const handleSort = (column: keyof Schedule) => {
if (sortColumn === column) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortColumn(column);
setSortDirection("asc");
}
};
return (
<div
className="grid h-full w-screen grid-cols-1 gap-4 px-8 md:grid-cols-5 lg:grid-cols-4 xl:grid-cols-10"
data-testid="monitor-page"
>
<AgentFlowList
className={column1}
flows={flows}
flowRuns={flowRuns}
selectedFlow={selectedFlow}
onSelectFlow={(f) => {
setSelectedRun(null);
setSelectedFlow(
f.id == selectedFlow?.id ? null : (f as GraphMetaWithRuns),
);
}}
/>
<FlowRunsList
className={column2}
flows={flows}
runs={[
...(selectedFlow
? flowRuns.filter((v) => v.graphID == selectedFlow.id)
: flowRuns),
].sort((a, b) => Number(a.startTime) - Number(b.startTime))}
selectedRun={selectedRun}
onSelectRun={(r) => setSelectedRun(r.id == selectedRun?.id ? null : r)}
/>
{(selectedRun && (
<FlowRunInfo
flow={selectedFlow || flows.find((f) => f.id == selectedRun.graphID)!}
flowRun={selectedRun}
className={column3}
/>
)) ||
(selectedFlow && (
<FlowInfo
flow={selectedFlow}
flowRuns={flowRuns.filter((r) => r.graphID == selectedFlow.id)}
className={column3}
refresh={() => {
fetchAgents();
setSelectedFlow(null);
setSelectedRun(null);
}}
/>
)) || (
<Card className={`p-6 ${column3}`}>
<FlowRunsStats flows={flows} flowRuns={flowRuns} />
</Card>
)}
<div className="col-span-full xl:col-span-6">
<SchedulesTable
schedules={schedules} // all schedules
agents={flows} // for filtering purpose
onRemoveSchedule={removeSchedule}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
/>
</div>
</div>
);
};
function flowRunFromExecutionMeta(
graphMeta: GraphMetaWithRuns,
executionMeta: ExecutionMeta,
): FlowRun {
return {
id: executionMeta.execution_id,
graphID: graphMeta.id,
graphVersion: graphMeta.version,
status: executionMeta.status,
startTime: executionMeta.started_at,
endTime: executionMeta.ended_at,
duration: executionMeta.duration,
totalRunTime: executionMeta.total_run_time,
} as FlowRun;
}
export default Monitor;

View File

@ -1,145 +1,7 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import AutoGPTServerAPI, {
GraphExecution,
Schedule,
GraphMeta,
} from "@/lib/autogpt-server-api";
import { redirect } from "next/navigation";
import { Card } from "@/components/ui/card";
import {
AgentFlowList,
FlowInfo,
FlowRunInfo,
FlowRunsList,
FlowRunsStats,
} from "@/components/monitor";
import { SchedulesTable } from "@/components/monitor/scheduleTable";
const Monitor = () => {
const [flows, setFlows] = useState<GraphMeta[]>([]);
const [executions, setExecutions] = useState<GraphExecution[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [selectedFlow, setSelectedFlow] = useState<GraphMeta | null>(null);
const [selectedRun, setSelectedRun] = useState<GraphExecution | null>(null);
const [sortColumn, setSortColumn] = useState<keyof Schedule>("id");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const api = useMemo(() => new AutoGPTServerAPI(), []);
const fetchSchedules = useCallback(async () => {
setSchedules(await api.listSchedules());
}, [api]);
const removeSchedule = useCallback(
async (scheduleId: string) => {
const removedSchedule = await api.deleteSchedule(scheduleId);
setSchedules(schedules.filter((s) => s.id !== removedSchedule.id));
},
[schedules, api],
);
const fetchAgents = useCallback(() => {
api.listGraphs().then((agent) => {
setFlows(agent);
});
api.getExecutions().then((executions) => {
setExecutions(executions);
});
}, [api]);
useEffect(() => {
fetchAgents();
}, [fetchAgents]);
useEffect(() => {
fetchSchedules();
}, [fetchSchedules]);
useEffect(() => {
const intervalId = setInterval(() => fetchAgents(), 5000);
return () => clearInterval(intervalId);
}, [fetchAgents, flows]);
const column1 = "md:col-span-2 xl:col-span-3 xxl:col-span-2";
const column2 = "md:col-span-3 lg:col-span-2 xl:col-span-3";
const column3 = "col-span-full xl:col-span-4 xxl:col-span-5";
const handleSort = (column: keyof Schedule) => {
if (sortColumn === column) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortColumn(column);
setSortDirection("asc");
}
};
return (
<div
className="grid grid-cols-1 gap-4 md:grid-cols-5 lg:grid-cols-4 xl:grid-cols-10"
data-testid="monitor-page"
>
<AgentFlowList
className={column1}
flows={flows}
executions={executions}
selectedFlow={selectedFlow}
onSelectFlow={(f) => {
setSelectedRun(null);
setSelectedFlow(f.id == selectedFlow?.id ? null : (f as GraphMeta));
}}
/>
<FlowRunsList
className={column2}
flows={flows}
executions={[
...(selectedFlow
? executions.filter((v) => v.graph_id == selectedFlow.id)
: executions),
].sort((a, b) => Number(b.started_at) - Number(a.started_at))}
selectedRun={selectedRun}
onSelectRun={(r) =>
setSelectedRun(r.execution_id == selectedRun?.execution_id ? null : r)
}
/>
{(selectedRun && (
<FlowRunInfo
flow={
selectedFlow || flows.find((f) => f.id == selectedRun.graph_id)!
}
execution={selectedRun}
className={column3}
/>
)) ||
(selectedFlow && (
<FlowInfo
flow={selectedFlow}
executions={executions.filter((e) => e.graph_id == selectedFlow.id)}
className={column3}
refresh={() => {
fetchAgents();
setSelectedFlow(null);
setSelectedRun(null);
}}
/>
)) || (
<Card className={`p-6 ${column3}`}>
<FlowRunsStats flows={flows} executions={executions} />
</Card>
)}
<div className="col-span-full xl:col-span-6">
<SchedulesTable
schedules={schedules} // all schedules
agents={flows} // for filtering purpose
onRemoveSchedule={removeSchedule}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
/>
</div>
</div>
);
};
export default Monitor;
export default function Page() {
redirect("/store");
}

View File

@ -1,6 +1,6 @@
"use client";
import { useSupabase } from "@/components/SupabaseProvider";
import { useSupabase } from "@/components/providers/SupabaseProvider";
import { Button } from "@/components/ui/button";
import useUser from "@/hooks/useUser";
import { useRouter } from "next/navigation";

View File

@ -2,17 +2,22 @@
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ThemeProviderProps } from "next-themes/dist/types";
import { BackendAPIProvider } from "@/lib/autogpt-server-api";
import { ThemeProviderProps } from "next-themes";
import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
import { TooltipProvider } from "@/components/ui/tooltip";
import SupabaseProvider from "@/components/SupabaseProvider";
import SupabaseProvider from "@/components/providers/SupabaseProvider";
import CredentialsProvider from "@/components/integrations/credentials-provider";
import { User } from "@supabase/supabase-js";
import { LaunchDarklyProvider } from "@/components/feature-flag/feature-flag-provider";
export function Providers({ children, ...props }: ThemeProviderProps) {
export function Providers({
children,
initialUser,
...props
}: ThemeProviderProps & { initialUser: User | null }) {
return (
<NextThemesProvider {...props}>
<SupabaseProvider>
<SupabaseProvider initialUser={initialUser}>
<BackendAPIProvider>
<CredentialsProvider>
<LaunchDarklyProvider>

View File

@ -1,46 +0,0 @@
"use server";
import { createServerClient } from "@/lib/supabase/server";
import * as Sentry from "@sentry/nextjs";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
const SignupFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
});
export async function signup(values: z.infer<typeof SignupFormSchema>) {
"use server";
return await Sentry.withServerActionInstrumentation(
"signup",
{},
async () => {
const supabase = createServerClient();
if (!supabase) {
redirect("/error");
}
// We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signUp(values);
if (error) {
if (error.message.includes("P0001")) {
return "Please join our waitlist for your turn: https://agpt.co/waitlist";
}
if (error.code?.includes("user_already_exists")) {
redirect("/login");
}
return error.message;
}
if (data.session) {
await supabase.auth.setSession(data.session);
}
revalidatePath("/", "layout");
redirect("/");
},
);
}

View File

@ -1,225 +0,0 @@
"use client";
import useUser from "@/hooks/useUser";
import { signup } from "./actions";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { PasswordInput } from "@/components/PasswordInput";
import { FaGoogle, FaGithub, FaDiscord, FaSpinner } from "react-icons/fa";
import { useState } from "react";
import { useSupabase } from "@/components/SupabaseProvider";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Checkbox } from "@/components/ui/checkbox";
const signupFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
agreeToTerms: z.boolean().refine((value) => value === true, {
message: "You must agree to the Terms of Use and Privacy Policy",
}),
});
export default function LoginPage() {
const { supabase, isLoading: isSupabaseLoading } = useSupabase();
const { user, isLoading: isUserLoading } = useUser();
const [feedback, setFeedback] = useState<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof signupFormSchema>>({
resolver: zodResolver(signupFormSchema),
defaultValues: {
email: "",
password: "",
agreeToTerms: false,
},
});
if (user) {
console.log("User exists, redirecting to home");
router.push("/");
}
if (isUserLoading || isSupabaseLoading || user) {
return (
<div className="flex h-[80vh] items-center justify-center">
<FaSpinner className="mr-2 h-16 w-16 animate-spin" />
</div>
);
}
if (!supabase) {
return (
<div>
User accounts are disabled because Supabase client is unavailable
</div>
);
}
async function handleSignInWithProvider(
provider: "google" | "github" | "discord",
) {
const { data, error } = await supabase!.auth.signInWithOAuth({
provider: provider,
options: {
redirectTo:
process.env.AUTH_CALLBACK_URL ??
`http://localhost:3000/auth/callback`,
},
});
if (!error) {
setFeedback(null);
return;
}
setFeedback(error.message);
}
const onSignup = async (data: z.infer<typeof signupFormSchema>) => {
if (await form.trigger()) {
setIsLoading(true);
const error = await signup(data);
setIsLoading(false);
if (error) {
setFeedback(error);
return;
}
setFeedback(null);
}
};
return (
<div className="flex h-[80vh] items-center justify-center">
<div className="w-full max-w-md space-y-6 rounded-lg p-8 shadow-md">
<h1 className="text-lg font-medium">Create a New Account</h1>
{/* <div className="mb-6 space-y-2">
<Button
className="w-full"
onClick={() => handleSignInWithProvider("google")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaGoogle className="mr-2 h-4 w-4" />
Sign in with Google
</Button>
<Button
className="w-full"
onClick={() => handleSignInWithProvider("github")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaGithub className="mr-2 h-4 w-4" />
Sign in with GitHub
</Button>
<Button
className="w-full"
onClick={() => handleSignInWithProvider("discord")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaDiscord className="mr-2 h-4 w-4" />
Sign in with Discord
</Button>
</div> */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSignup)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="user@email.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder="password" {...field} />
</FormControl>
<FormDescription>
Password needs to be at least 6 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
I agree to the{" "}
<Link
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
className="underline"
>
Terms of Use
</Link>{" "}
and{" "}
<Link
href="https://www.notion.so/auto-gpt/Privacy-Policy-ab11c9c20dbd4de1a15dcffe84d77984"
className="underline"
>
Privacy Policy
</Link>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<div className="mb-6 mt-8 flex w-full space-x-4">
<Button
className="flex w-full justify-center"
variant="outline"
type="button"
onClick={form.handleSubmit(onSignup)}
disabled={isLoading}
>
Sign up
</Button>
</div>
<div className="w-full text-center">
<Link href={"/login"} className="w-fit text-xs hover:underline">
Already a member? Log In here
</Link>
</div>
</form>
<p className="text-sm text-red-500">{feedback}</p>
</Form>
</div>
</div>
);
}

View File

@ -0,0 +1,167 @@
"use client";
import * as React from "react";
import { AgentTable } from "@/components/agptui/AgentTable";
import { AgentTableRowProps } from "@/components/agptui/AgentTableRow";
import { Button } from "@/components/agptui/Button";
import { Separator } from "@/components/ui/separator";
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import { createClient } from "@/lib/supabase/client";
import { StatusType } from "@/components/agptui/Status";
import { PublishAgentPopout } from "@/components/agptui/composite/PublishAgentPopout";
import { useCallback, useEffect, useState } from "react";
import {
StoreSubmissionsResponse,
StoreSubmissionRequest,
} from "@/lib/autogpt-server-api/types";
async function getDashboardData() {
const supabase = createClient();
if (!supabase) {
return { submissions: [] };
}
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
console.warn("--- No session found in profile page");
return { profile: null };
}
const api = new AutoGPTServerAPI(
process.env.NEXT_PUBLIC_AGPT_SERVER_URL,
process.env.NEXT_PUBLIC_AGPT_WS_SERVER_URL,
supabase,
);
try {
const submissions = await api.getStoreSubmissions();
return {
submissions,
};
} catch (error) {
console.error("Error fetching profile:", error);
return {
profile: null,
};
}
}
export default function Page({}: {}) {
const [submissions, setSubmissions] = useState<StoreSubmissionsResponse>();
const [openPopout, setOpenPopout] = useState<boolean>(false);
const [submissionData, setSubmissionData] =
useState<StoreSubmissionRequest>();
const [popoutStep, setPopoutStep] = useState<"select" | "info" | "review">(
"info",
);
const fetchData = useCallback(async () => {
const { submissions } = await getDashboardData();
if (submissions) {
setSubmissions(submissions as StoreSubmissionsResponse);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const onEditSubmission = useCallback((submission: StoreSubmissionRequest) => {
setSubmissionData(submission);
setPopoutStep("review");
setOpenPopout(true);
}, []);
const onDeleteSubmission = useCallback(
(submission_id: string) => {
const supabase = createClient();
if (!supabase) {
return;
}
const api = new AutoGPTServerAPI(
process.env.NEXT_PUBLIC_AGPT_SERVER_URL,
process.env.NEXT_PUBLIC_AGPT_WS_SERVER_URL,
supabase,
);
api.deleteStoreSubmission(submission_id);
fetchData();
},
[fetchData],
);
const onOpenPopout = useCallback(() => {
setPopoutStep("select");
setOpenPopout(true);
}, []);
return (
<main className="flex-1 py-8">
{/* Header Section */}
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-6">
<h1 className="text-4xl font-medium text-neutral-900 dark:text-neutral-100">
Agent dashboard
</h1>
<div className="space-y-2">
<h2 className="text-xl font-medium text-neutral-900 dark:text-neutral-100">
Submit a New Agent
</h2>
<p className="text-sm text-[#707070] dark:text-neutral-400">
Select from the list of agents you currently have, or upload from
your local machine.
</p>
</div>
</div>
<PublishAgentPopout
trigger={
<Button
variant="default"
size="sm"
onClick={onOpenPopout}
className="h-9 rounded-full bg-black px-4 text-sm font-medium text-white hover:bg-neutral-700 dark:hover:bg-neutral-600"
>
Submit agent
</Button>
}
openPopout={openPopout}
inputStep={popoutStep}
submissionData={submissionData}
/>
</div>
<Separator className="mb-8" />
{/* Agents Section */}
<div>
<h2 className="mb-4 text-xl font-bold text-neutral-900 dark:text-neutral-100">
Your uploaded agents
</h2>
<AgentTable
agents={
(submissions?.submissions.map((submission, index) => ({
id: index,
agent_id: submission.agent_id,
agent_version: submission.agent_version,
sub_heading: submission.sub_heading,
date_submitted: submission.date_submitted,
agentName: submission.name,
description: submission.description,
imageSrc: submission.image_urls || [""],
dateSubmitted: new Date(
submission.date_submitted,
).toLocaleDateString(),
status: submission.status.toLowerCase() as StatusType,
runs: submission.runs,
rating: submission.rating,
})) as AgentTableRowProps[]) || []
}
onEditSubmission={onEditSubmission}
onDeleteSubmission={onDeleteSubmission}
/>
</div>
</main>
);
}

View File

@ -0,0 +1,23 @@
import * as React from "react";
import { Sidebar } from "@/components/agptui/Sidebar";
export default function Layout({ children }: { children: React.ReactNode }) {
const sidebarLinkGroups = [
{
links: [
{ text: "Creator Dashboard", href: "/store/dashboard" },
{ text: "Agent dashboard", href: "/store/agent-dashboard" },
{ text: "Integrations", href: "/store/integrations" },
{ text: "Profile", href: "/store/profile" },
{ text: "Settings", href: "/store/settings" },
],
},
];
return (
<div className="flex min-h-screen w-screen max-w-[1360px] flex-col lg:flex-row">
<Sidebar linkGroups={sidebarLinkGroups} />
<div className="pl-4">{children}</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
import * as React from "react";
import { ProfileInfoForm } from "@/components/agptui/ProfileInfoForm";
import AutoGPTServerAPIServerSide from "@/lib/autogpt-server-api";
import { createServerClient } from "@/lib/supabase/server";
import { CreatorDetails } from "@/lib/autogpt-server-api/types";
async function getProfileData() {
// Get the supabase client first
const supabase = createServerClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
console.warn("--- No session found in profile page");
return { profile: null };
}
// Create API client with the same supabase instance
const api = new AutoGPTServerAPIServerSide(
process.env.NEXT_PUBLIC_AGPT_SERVER_URL,
process.env.NEXT_PUBLIC_AGPT_WS_SERVER_URL,
supabase, // Pass the supabase client instance
);
try {
const profile = await api.getStoreProfile("profile");
return {
profile,
};
} catch (error) {
console.error("Error fetching profile:", error);
return {
profile: null,
};
}
}
export default async function Page({}: {}) {
const { profile } = await getProfileData();
if (!profile) {
return (
<div className="flex flex-col items-center justify-center p-4">
<p>Please log in to view your profile</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center px-4">
<ProfileInfoForm profile={profile as CreatorDetails} />
</div>
);
}

View File

@ -0,0 +1,6 @@
import * as React from "react";
import { SettingsInputForm } from "@/components/agptui/SettingsInputForm";
export default function Page() {
return <SettingsInputForm />;
}

View File

@ -0,0 +1,90 @@
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import { BreadCrumbs } from "@/components/agptui/BreadCrumbs";
import { AgentInfo } from "@/components/agptui/AgentInfo";
import { AgentImages } from "@/components/agptui/AgentImages";
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
import { BecomeACreator } from "@/components/agptui/BecomeACreator";
import { Separator } from "@/components/ui/separator";
import { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: { creator: string; slug: string };
}): Promise<Metadata> {
const api = new AutoGPTServerAPI();
const agent = await api.getStoreAgent(params.creator, params.slug);
return {
title: `${agent.agent_name} - AutoGPT Store`,
description: agent.description,
};
}
// export async function generateStaticParams() {
// const api = new AutoGPTServerAPI();
// const agents = await api.getStoreAgents({ featured: true });
// return agents.agents.map((agent) => ({
// creator: agent.creator,
// slug: agent.slug,
// }));
// }
export default async function Page({
params,
}: {
params: { creator: string; slug: string };
}) {
const api = new AutoGPTServerAPI();
const agent = await api.getStoreAgent(params.creator, params.slug);
const otherAgents = await api.getStoreAgents({ creator: params.creator });
const similarAgents = await api.getStoreAgents({
search_query: agent.categories[0],
});
const breadcrumbs = [
{ name: "Store", link: "/store" },
{ name: agent.creator, link: `/store/creator/${agent.creator}` },
{ name: agent.agent_name, link: "#" },
];
return (
<div className="mx-auto w-screen max-w-[1360px]">
<main className="px-4 md:mt-4 lg:mt-8">
<BreadCrumbs items={breadcrumbs} />
<div className="mt-4 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 md:mt-8 md:flex-row md:gap-8">
<div className="w-full md:w-auto md:shrink-0">
<AgentInfo
name={agent.agent_name}
creator={agent.creator}
shortDescription={agent.description}
longDescription={agent.description}
rating={agent.rating}
runs={agent.runs}
categories={agent.categories}
lastUpdated={agent.updated_at}
version={agent.versions[agent.versions.length - 1]}
/>
</div>
<AgentImages images={agent.agent_image} />
</div>
<Separator className="my-6" />
<AgentsSection
agents={otherAgents.agents}
sectionTitle={`Other agents by ${agent.creator}`}
/>
<Separator className="my-6" />
<AgentsSection
agents={similarAgents.agents}
sectionTitle="Similar agents"
/>
<BecomeACreator
title="Become a Creator"
description="Join our ever-growing community of hackers and tinkerers"
buttonText="Become a Creator"
/>
</main>
</div>
);
}

View File

@ -0,0 +1,93 @@
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import {
CreatorDetails as Creator,
StoreAgent,
} from "@/lib/autogpt-server-api";
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
import { BreadCrumbs } from "@/components/agptui/BreadCrumbs";
import { Metadata } from "next";
import { CreatorInfoCard } from "@/components/agptui/CreatorInfoCard";
import { CreatorLinks } from "@/components/agptui/CreatorLinks";
export async function generateMetadata({
params,
}: {
params: { creator: string };
}): Promise<Metadata> {
const api = new AutoGPTServerAPI();
const creator = await api.getStoreCreator(params.creator);
return {
title: `${creator.name} - AutoGPT Store`,
description: creator.description,
};
}
// export async function generateStaticParams() {
// const api = new AutoGPTServerAPI();
// const creators = await api.getStoreCreators({ featured: true });
// return creators.creators.map((creator) => ({
// creator: creator.username,
// }));
// }
export default async function Page({
params,
}: {
params: { creator: string };
}) {
const api = new AutoGPTServerAPI();
try {
const creator = await api.getStoreCreator(params.creator);
const creatorAgents = await api.getStoreAgents({ creator: params.creator });
return (
<div className="mx-auto w-screen max-w-[1360px]">
<main className="px-4 md:mt-4 lg:mt-8">
<BreadCrumbs
items={[
{ name: "Store", link: "/store" },
{ name: creator.name, link: "#" },
]}
/>
<div className="mt-4 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 md:mt-8 md:flex-row md:gap-8">
<div className="w-full md:w-auto md:shrink-0">
<CreatorInfoCard
username={creator.name}
handle={creator.username}
avatarSrc={creator.avatar_url}
categories={creator.top_categories}
averageRating={creator.agent_rating}
totalRuns={creator.agent_runs}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-4 sm:gap-6 md:gap-8">
<div className="font-neue text-2xl font-normal leading-normal text-neutral-900 sm:text-3xl md:text-[35px] md:leading-[45px]">
{creator.description}
</div>
<CreatorLinks links={creator.links} />
</div>
</div>
<div className="mt-8 sm:mt-12 md:mt-16">
<hr className="w-full bg-neutral-700" />
<AgentsSection
agents={creatorAgents.agents}
hideAvatars={true}
sectionTitle={`Agents by ${creator.name}`}
/>
</div>
</main>
</div>
);
} catch (error) {
return (
<div className="flex h-screen w-full items-center justify-center">
<div className="font-neue text-2xl text-neutral-900">
Creator not found
</div>
</div>
);
}
}

View File

@ -0,0 +1,191 @@
import * as React from "react";
import { HeroSection } from "@/components/agptui/composite/HeroSection";
import {
FeaturedSection,
FeaturedAgent,
} from "@/components/agptui/composite/FeaturedSection";
import {
AgentsSection,
Agent,
} from "@/components/agptui/composite/AgentsSection";
import { BecomeACreator } from "@/components/agptui/BecomeACreator";
import {
FeaturedCreators,
FeaturedCreator,
} from "@/components/agptui/composite/FeaturedCreators";
import { Separator } from "@/components/ui/separator";
import AutoGPTServerAPIServerSide from "@/lib/autogpt-server-api/clientServer";
import { Metadata } from "next";
import { createServerClient } from "@/lib/supabase/server";
import {
StoreAgentsResponse,
CreatorsResponse,
} from "@/lib/autogpt-server-api/types";
export const dynamic = "force-dynamic";
async function getStoreData() {
try {
const supabase = createServerClient();
const {
data: { session },
} = await supabase.auth.getSession();
const api = new AutoGPTServerAPIServerSide(
process.env.NEXT_PUBLIC_AGPT_SERVER_URL,
process.env.NEXT_PUBLIC_AGPT_WS_SERVER_URL,
supabase,
);
// Add error handling and default values
let featuredAgents: StoreAgentsResponse = {
agents: [],
pagination: {
total_items: 0,
total_pages: 0,
current_page: 0,
page_size: 0,
},
};
let topAgents: StoreAgentsResponse = {
agents: [],
pagination: {
total_items: 0,
total_pages: 0,
current_page: 0,
page_size: 0,
},
};
let featuredCreators: CreatorsResponse = {
creators: [],
pagination: {
total_items: 0,
total_pages: 0,
current_page: 0,
page_size: 0,
},
};
try {
[featuredAgents, topAgents, featuredCreators] = await Promise.all([
api.getStoreAgents({ featured: true }),
api.getStoreAgents({ sorted_by: "runs" }),
api.getStoreCreators({ featured: true, sorted_by: "num_agents" }),
]);
} catch (error) {
console.error("Error fetching store data:", error);
}
return {
featuredAgents,
topAgents,
featuredCreators,
};
} catch (error) {
console.error("Error in getStoreData:", error);
return {
featuredAgents: {
agents: [],
pagination: {
total_items: 0,
total_pages: 0,
current_page: 0,
page_size: 0,
},
},
topAgents: {
agents: [],
pagination: {
total_items: 0,
total_pages: 0,
current_page: 0,
page_size: 0,
},
},
featuredCreators: {
creators: [],
pagination: {
total_items: 0,
total_pages: 0,
current_page: 0,
page_size: 0,
},
},
};
}
}
// FIX: Correct metadata
export const metadata: Metadata = {
title: "Agent Store - NextGen AutoGPT",
description: "Find and use AI Agents created by our community",
applicationName: "NextGen AutoGPT Store",
authors: [{ name: "AutoGPT Team" }],
keywords: [
"AI agents",
"automation",
"artificial intelligence",
"AutoGPT",
"marketplace",
],
robots: {
index: true,
follow: true,
},
openGraph: {
title: "Agent Store - NextGen AutoGPT",
description: "Find and use AI Agents created by our community",
type: "website",
siteName: "NextGen AutoGPT Store",
images: [
{
url: "/images/store-og.png",
width: 1200,
height: 630,
alt: "NextGen AutoGPT Store",
},
],
},
twitter: {
card: "summary_large_image",
title: "Agent Store - NextGen AutoGPT",
description: "Find and use AI Agents created by our community",
images: ["/images/store-twitter.png"],
},
icons: {
icon: "/favicon.ico",
shortcut: "/favicon-16x16.png",
apple: "/apple-touch-icon.png",
},
};
export default async function Page({}: {}) {
// Get data server-side
const { featuredAgents, topAgents, featuredCreators } = await getStoreData();
return (
<div className="mx-auto w-screen max-w-[1360px]">
<main className="px-4">
<HeroSection />
<FeaturedSection
featuredAgents={featuredAgents.agents as FeaturedAgent[]}
/>
<Separator />
<AgentsSection
sectionTitle="Top Agents"
agents={topAgents.agents as Agent[]}
/>
<Separator />
<FeaturedCreators
featuredCreators={featuredCreators.creators as FeaturedCreator[]}
/>
<Separator />
<BecomeACreator
title="Become a Creator"
description="Join our ever-growing community of hackers and tinkerers"
buttonText="Become a Creator"
/>
</main>
</div>
);
}

View File

@ -0,0 +1,182 @@
"use client";
import { useState, useEffect } from "react";
import { AutoGPTServerAPI } from "@/lib/autogpt-server-api/client";
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
import { SearchBar } from "@/components/agptui/SearchBar";
import { FeaturedCreators } from "@/components/agptui/composite/FeaturedCreators";
import { Separator } from "@/components/ui/separator";
import { SearchFilterChips } from "@/components/agptui/SearchFilterChips";
import { SortDropdown } from "@/components/agptui/SortDropdown";
export default function Page({
searchParams,
}: {
searchParams: { searchTerm?: string; sort?: string };
}) {
return (
<SearchResults
searchTerm={searchParams.searchTerm || ""}
sort={searchParams.sort || "trending"}
/>
);
}
function SearchResults({
searchTerm,
sort,
}: {
searchTerm: string;
sort: string;
}) {
const [showAgents, setShowAgents] = useState(true);
const [showCreators, setShowCreators] = useState(true);
const [agents, setAgents] = useState<any[]>([]);
const [creators, setCreators] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
const api = new AutoGPTServerAPI();
try {
const [agentsRes, creatorsRes] = await Promise.all([
api.getStoreAgents({
search_query: searchTerm,
sorted_by: sort,
}),
api.getStoreCreators({
search_query: searchTerm,
}),
]);
setAgents(agentsRes.agents || []);
setCreators(creatorsRes.creators || []);
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [searchTerm, sort]);
const agentsCount = agents.length;
const creatorsCount = creators.length;
const totalCount = agentsCount + creatorsCount;
const handleFilterChange = (value: string) => {
if (value === "agents") {
setShowAgents(true);
setShowCreators(false);
} else if (value === "creators") {
setShowAgents(false);
setShowCreators(true);
} else {
setShowAgents(true);
setShowCreators(true);
}
};
const handleSortChange = (sortValue: string) => {
let sortBy = "recent";
if (sortValue === "runs") {
sortBy = "runs";
} else if (sortValue === "rating") {
sortBy = "rating";
}
const sortedAgents = [...agents].sort((a, b) => {
if (sortBy === "runs") {
return b.runs - a.runs;
} else if (sortBy === "rating") {
return b.rating - a.rating;
} else {
return (
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
);
}
});
const sortedCreators = [...creators].sort((a, b) => {
if (sortBy === "runs") {
return b.agent_runs - a.agent_runs;
} else if (sortBy === "rating") {
return b.agent_rating - a.agent_rating;
} else {
// Creators don't have updated_at, sort by number of agents as fallback
return b.num_agents - a.num_agents;
}
});
setAgents(sortedAgents);
setCreators(sortedCreators);
};
return (
<div className="w-full">
<div className="mx-auto min-h-screen max-w-[1440px] px-10 lg:min-w-[1440px]">
<div className="mt-8 flex items-center">
<div className="flex-1">
<h2 className="font-geist text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Results for:
</h2>
<h1 className="font-poppins text-2xl font-semibold leading-loose text-neutral-800 dark:text-neutral-100">
{searchTerm}
</h1>
</div>
<div className="flex-none">
<SearchBar width="w-[439px]" />
</div>
</div>
{isLoading ? (
<div className="mt-20 flex flex-col items-center justify-center">
<p className="text-neutral-500 dark:text-neutral-400">Loading...</p>
</div>
) : totalCount > 0 ? (
<>
<div className="mt-8 flex items-center justify-between">
<SearchFilterChips
totalCount={totalCount}
agentsCount={agentsCount}
creatorsCount={creatorsCount}
onFilterChange={handleFilterChange}
/>
<SortDropdown onSort={handleSortChange} />
</div>
{/* Content section */}
<div className="min-h-[500px] max-w-[1440px]">
{showAgents && agentsCount > 0 && (
<div className="mt-8">
<AgentsSection agents={agents} sectionTitle="Agents" />
</div>
)}
{showAgents && agentsCount > 0 && creatorsCount > 0 && (
<Separator />
)}
{showCreators && creatorsCount > 0 && (
<FeaturedCreators
featuredCreators={creators}
title="Creators"
/>
)}
</div>
</>
) : (
<div className="mt-20 flex flex-col items-center justify-center">
<h3 className="mb-2 text-xl font-medium text-neutral-600 dark:text-neutral-300">
No results found
</h3>
<p className="text-neutral-500 dark:text-neutral-400">
Try adjusting your search terms or filters
</p>
</div>
)}
</div>
</div>
);
}

View File

@ -19,8 +19,8 @@ import {
NodeExecutionResult,
BlockUIType,
BlockCost,
useBackendAPI,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
beautifyString,
cn,
@ -258,7 +258,7 @@ export function CustomNode({
) : (
propKey != "credentials" && (
<div className="flex gap-1">
<span className="text-m green mb-0 text-gray-900">
<span className="text-m green mb-0 text-gray-900 dark:text-gray-100">
{propSchema.title || beautifyString(propKey)}
</span>
<SchemaTooltip description={propSchema.description} />
@ -460,49 +460,54 @@ export function CustomNode({
"custom-node",
"dark-theme",
"rounded-xl",
"bg-white/[.9]",
"border border-gray-300",
"bg-white/[.9] dark:bg-gray-800/[.9]",
"border border-gray-300 dark:border-gray-600",
data.uiType === BlockUIType.NOTE ? "w-[300px]" : "w-[500px]",
data.uiType === BlockUIType.NOTE ? "bg-yellow-100" : "bg-white",
data.uiType === BlockUIType.NOTE
? "bg-yellow-100 dark:bg-yellow-900"
: "bg-white dark:bg-gray-800",
selected ? "shadow-2xl" : "",
]
.filter(Boolean)
.join(" ");
const errorClass =
hasConfigErrors || hasOutputError ? "border-red-200 border-2" : "";
hasConfigErrors || hasOutputError
? "border-red-200 dark:border-red-800 border-2"
: "";
const statusClass = (() => {
if (hasConfigErrors || hasOutputError) return "border-red-200 border-4";
if (hasConfigErrors || hasOutputError)
return "border-red-200 dark:border-red-800 border-4";
switch (data.status?.toLowerCase()) {
case "completed":
return "border-green-200 border-4";
return "border-green-200 dark:border-green-800 border-4";
case "running":
return "border-yellow-200 border-4";
return "border-yellow-200 dark:border-yellow-800 border-4";
case "failed":
return "border-red-200 border-4";
return "border-red-200 dark:border-red-800 border-4";
case "incomplete":
return "border-purple-200 border-4";
return "border-purple-200 dark:border-purple-800 border-4";
case "queued":
return "border-cyan-200 border-4";
return "border-cyan-200 dark:border-cyan-800 border-4";
default:
return "";
}
})();
const statusBackgroundClass = (() => {
if (hasConfigErrors || hasOutputError) return "bg-red-200";
if (hasConfigErrors || hasOutputError) return "bg-red-200 dark:bg-red-800";
switch (data.status?.toLowerCase()) {
case "completed":
return "bg-green-200";
return "bg-green-200 dark:bg-green-800";
case "running":
return "bg-yellow-200";
return "bg-yellow-200 dark:bg-yellow-800";
case "failed":
return "bg-red-200";
return "bg-red-200 dark:bg-red-800";
case "incomplete":
return "bg-purple-200";
return "bg-purple-200 dark:bg-purple-800";
case "queued":
return "bg-cyan-200";
return "bg-cyan-200 dark:bg-cyan-800";
default:
return "";
}
@ -591,36 +596,36 @@ export function CustomNode({
);
const LineSeparator = () => (
<div className="bg-white pt-6">
<Separator.Root className="h-[1px] w-full bg-gray-300"></Separator.Root>
<div className="bg-white pt-6 dark:bg-gray-800">
<Separator.Root className="h-[1px] w-full bg-gray-300 dark:bg-gray-600"></Separator.Root>
</div>
);
const ContextMenuContent = () => (
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md">
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800">
<ContextMenu.Item
onSelect={copyNode}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100"
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<CopyIcon className="mr-2 h-5 w-5" />
<span>Copy</span>
<CopyIcon className="mr-2 h-5 w-5 dark:text-gray-100" />
<span className="dark:text-gray-100">Copy</span>
</ContextMenu.Item>
{nodeFlowId && (
<ContextMenu.Item
onSelect={() => window.open(`/build?flowID=${nodeFlowId}`)}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100"
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ExitIcon className="mr-2 h-5 w-5" />
<span>Open agent</span>
<ExitIcon className="mr-2 h-5 w-5 dark:text-gray-100" />
<span className="dark:text-gray-100">Open agent</span>
</ContextMenu.Item>
)}
<ContextMenu.Separator className="my-1 h-px bg-gray-300" />
<ContextMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" />
<ContextMenu.Item
onSelect={deleteNode}
className="flex cursor-pointer items-center rounded-md px-3 py-2 text-red-500 hover:bg-gray-100"
className="flex cursor-pointer items-center rounded-md px-3 py-2 text-red-500 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<TrashIcon className="mr-2 h-5 w-5 text-red-500" />
<span>Delete</span>
<TrashIcon className="mr-2 h-5 w-5 text-red-500 dark:text-red-400" />
<span className="dark:text-red-400">Delete</span>
</ContextMenu.Item>
</ContextMenu.Content>
);

View File

@ -70,9 +70,8 @@ export const FlowContext = createContext<FlowContextType | null>(null);
const FlowEditor: React.FC<{
flowID?: string;
template?: boolean;
className?: string;
}> = ({ flowID, template, className }) => {
}> = ({ flowID, className }) => {
const {
addNodes,
addEdges,
@ -106,7 +105,7 @@ const FlowEditor: React.FC<{
setNodes,
edges,
setEdges,
} = useAgentGraph(flowID, template, visualizeBeads !== "no");
} = useAgentGraph(flowID, visualizeBeads !== "no");
const router = useRouter();
const pathname = usePathname();
@ -661,9 +660,10 @@ const FlowEditor: React.FC<{
deleteKeyCode={["Backspace", "Delete"]}
minZoom={0.2}
maxZoom={2}
className="dark:bg-slate-900"
>
<Controls />
<Background />
<Background className="dark:bg-slate-800" />
<ControlPanel
className="absolute z-10"
controls={editorControls}

View File

@ -35,7 +35,7 @@ const NodeHandle: FC<HandleProps> = ({
const label = (
<div className="flex flex-grow flex-row">
<span className="text-m green flex items-end pr-2 text-gray-900">
<span className="text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100">
{title || schema.title || beautifyString(keyName.toLowerCase())}
{isRequired ? "*" : ""}
</span>
@ -48,10 +48,10 @@ const NodeHandle: FC<HandleProps> = ({
const Dot = () => {
const color = isConnected
? getTypeBgColor(schema.type || "any")
: "border-gray-300";
: "border-gray-300 dark:border-gray-600";
return (
<div
className={`${color} m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300`}
className={`${color} m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300 dark:bg-slate-800 dark:group-hover:bg-gray-700`}
/>
);
};

View File

@ -7,7 +7,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "./ui/button";
import { useSupabase } from "./SupabaseProvider";
import { useSupabase } from "./providers/SupabaseProvider";
import { useRouter } from "next/navigation";
import useUser from "@/hooks/useUser";

View File

@ -14,15 +14,18 @@ const SchemaTooltip: React.FC<{ description?: string }> = ({ description }) => {
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<Info className="rounded-full p-1 hover:bg-gray-300" size={24} />
<Info
className="rounded-full p-1 hover:bg-gray-300 dark:hover:bg-gray-700"
size={24}
/>
</TooltipTrigger>
<TooltipContent className="tooltip-content max-w-xs">
<TooltipContent className="tooltip-content max-w-xs bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100">
<ReactMarkdown
components={{
a: ({ node, ...props }) => (
<a
target="_blank"
className="text-blue-400 underline"
className="text-blue-400 underline dark:text-blue-300"
{...props}
/>
),

View File

@ -1,12 +1,20 @@
"use client";
import React, { useEffect, useState } from "react";
import { Button } from "./ui/button";
import { QuestionMarkCircledIcon } from "@radix-ui/react-icons";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
const TallyPopupSimple = () => {
const [isFormVisible, setIsFormVisible] = useState(false);
const router = useRouter();
const pathname = usePathname();
const [show_tutorial, setShowTutorial] = useState(false);
useEffect(() => {
setShowTutorial(pathname.includes("build"));
}, [pathname]);
useEffect(() => {
// Load Tally script
@ -49,21 +57,23 @@ const TallyPopupSimple = () => {
return (
<div className="fixed bottom-1 right-6 z-50 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
{show_tutorial && (
<Button
variant="default"
onClick={resetTutorial}
className="font-inter mb-0 h-14 w-28 rounded-2xl bg-[rgba(65,65,64,1)] text-left text-lg font-medium leading-6"
>
Tutorial
</Button>
)}
<Button
variant="default"
onClick={resetTutorial}
className="font-inter mb-0 h-14 w-28 rounded-2xl bg-[rgba(65,65,64,1)] text-left text-lg font-medium leading-6"
>
Tutorial
</Button>
<Button
className="h-14 w-14 rounded-2xl bg-[rgba(65,65,64,1)]"
className="h-14 w-14 rounded-full bg-[rgba(65,65,64,1)]"
variant="default"
data-tally-open="3yx2L0"
data-tally-emoji-text="👋"
data-tally-emoji-animation="wave"
>
<QuestionMarkCircledIcon className="h-6 w-6" />
<QuestionMarkCircledIcon className="h-14 w-14" />
<span className="sr-only">Reach Out</span>
</Button>
</div>

View File

@ -1,149 +1,149 @@
"use client";
// "use client";
import {
Dialog,
DialogContent,
DialogClose,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from "@/components/ui/multiselect";
import { Controller, useForm } from "react-hook-form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useState } from "react";
import { addFeaturedAgent } from "./actions";
import { Agent } from "@/lib/marketplace-api/types";
// import {
// Dialog,
// DialogContent,
// DialogClose,
// DialogFooter,
// DialogHeader,
// DialogTitle,
// DialogTrigger,
// } from "@/components/ui/dialog";
// import { Button } from "@/components/ui/button";
// import {
// MultiSelector,
// MultiSelectorContent,
// MultiSelectorInput,
// MultiSelectorItem,
// MultiSelectorList,
// MultiSelectorTrigger,
// } from "@/components/ui/multiselect";
// import { Controller, useForm } from "react-hook-form";
// import {
// Select,
// SelectContent,
// SelectItem,
// SelectTrigger,
// SelectValue,
// } from "@/components/ui/select";
// import { useState } from "react";
// import { addFeaturedAgent } from "./actions";
// import { Agent } from "@/lib/marketplace-api/types";
type FormData = {
agent: string;
categories: string[];
};
// type FormData = {
// agent: string;
// categories: string[];
// };
export const AdminAddFeaturedAgentDialog = ({
categories,
agents,
}: {
categories: string[];
agents: Agent[];
}) => {
const [selectedAgent, setSelectedAgent] = useState<string>("");
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
// export const AdminAddFeaturedAgentDialog = ({
// categories,
// agents,
// }: {
// categories: string[];
// agents: Agent[];
// }) => {
// const [selectedAgent, setSelectedAgent] = useState<string>("");
// const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const {
control,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<FormData>({
defaultValues: {
agent: "",
categories: [],
},
});
// const {
// control,
// handleSubmit,
// watch,
// setValue,
// formState: { errors },
// } = useForm<FormData>({
// defaultValues: {
// agent: "",
// categories: [],
// },
// });
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
Add Featured Agent
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Featured Agent</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<Controller
name="agent"
control={control}
rules={{ required: true }}
render={({ field }) => (
<div>
<label htmlFor={field.name}>Agent</label>
<Select
onValueChange={(value) => {
field.onChange(value);
setSelectedAgent(value);
}}
value={field.value || ""}
>
<SelectTrigger>
<SelectValue placeholder="Select an agent" />
</SelectTrigger>
<SelectContent>
{/* Populate with agents */}
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
/>
<Controller
name="categories"
control={control}
render={({ field }) => (
<MultiSelector
values={field.value || []}
onValuesChange={(values) => {
field.onChange(values);
setSelectedCategories(values);
}}
>
<MultiSelectorTrigger>
<MultiSelectorInput placeholder="Select categories" />
</MultiSelectorTrigger>
<MultiSelectorContent>
<MultiSelectorList>
{categories.map((category) => (
<MultiSelectorItem key={category} value={category}>
{category}
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
type="submit"
onClick={async () => {
// Handle adding the featured agent
await addFeaturedAgent(selectedAgent, selectedCategories);
// close the dialog
}}
>
Add
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
// return (
// <Dialog>
// <DialogTrigger asChild>
// <Button variant="outline" size="sm">
// Add Featured Agent
// </Button>
// </DialogTrigger>
// <DialogContent>
// <DialogHeader>
// <DialogTitle>Add Featured Agent</DialogTitle>
// </DialogHeader>
// <div className="flex flex-col gap-4">
// <Controller
// name="agent"
// control={control}
// rules={{ required: true }}
// render={({ field }) => (
// <div>
// <label htmlFor={field.name}>Agent</label>
// <Select
// onValueChange={(value) => {
// field.onChange(value);
// setSelectedAgent(value);
// }}
// value={field.value || ""}
// >
// <SelectTrigger>
// <SelectValue placeholder="Select an agent" />
// </SelectTrigger>
// <SelectContent>
// {/* Populate with agents */}
// {agents.map((agent) => (
// <SelectItem key={agent.id} value={agent.id}>
// {agent.name}
// </SelectItem>
// ))}
// </SelectContent>
// </Select>
// </div>
// )}
// />
// <Controller
// name="categories"
// control={control}
// render={({ field }) => (
// <MultiSelector
// values={field.value || []}
// onValuesChange={(values) => {
// field.onChange(values);
// setSelectedCategories(values);
// }}
// >
// <MultiSelectorTrigger>
// <MultiSelectorInput placeholder="Select categories" />
// </MultiSelectorTrigger>
// <MultiSelectorContent>
// <MultiSelectorList>
// {categories.map((category) => (
// <MultiSelectorItem key={category} value={category}>
// {category}
// </MultiSelectorItem>
// ))}
// </MultiSelectorList>
// </MultiSelectorContent>
// </MultiSelector>
// )}
// />
// </div>
// <DialogFooter>
// <DialogClose asChild>
// <Button variant="outline">Cancel</Button>
// </DialogClose>
// <DialogClose asChild>
// <Button
// type="submit"
// onClick={async () => {
// // Handle adding the featured agent
// await addFeaturedAgent(selectedAgent, selectedCategories);
// // close the dialog
// }}
// >
// Add
// </Button>
// </DialogClose>
// </DialogFooter>
// </DialogContent>
// </Dialog>
// );
// };

View File

@ -1,74 +1,74 @@
import { Button } from "@/components/ui/button";
import {
getFeaturedAgents,
removeFeaturedAgent,
getCategories,
getNotFeaturedAgents,
} from "./actions";
// import { Button } from "@/components/ui/button";
// import {
// getFeaturedAgents,
// removeFeaturedAgent,
// getCategories,
// getNotFeaturedAgents,
// } from "./actions";
import FeaturedAgentsTable from "./FeaturedAgentsTable";
import { AdminAddFeaturedAgentDialog } from "./AdminAddFeaturedAgentDialog";
import { revalidatePath } from "next/cache";
import * as Sentry from "@sentry/nextjs";
// import FeaturedAgentsTable from "./FeaturedAgentsTable";
// import { AdminAddFeaturedAgentDialog } from "./AdminAddFeaturedAgentDialog";
// import { revalidatePath } from "next/cache";
// import * as Sentry from "@sentry/nextjs";
export default async function AdminFeaturedAgentsControl({
className,
}: {
className?: string;
}) {
// add featured agent button
// modal to select agent?
// modal to select categories?
// table of featured agents
// in table
// remove featured agent button
// edit featured agent categories button
// table footer
// Next page button
// Previous page button
// Page number input
// Page size input
// Total pages input
// Go to page button
// export default async function AdminFeaturedAgentsControl({
// className,
// }: {
// className?: string;
// }) {
// // add featured agent button
// // modal to select agent?
// // modal to select categories?
// // table of featured agents
// // in table
// // remove featured agent button
// // edit featured agent categories button
// // table footer
// // Next page button
// // Previous page button
// // Page number input
// // Page size input
// // Total pages input
// // Go to page button
const page = 1;
const pageSize = 10;
// const page = 1;
// const pageSize = 10;
const agents = await getFeaturedAgents(page, pageSize);
// const agents = await getFeaturedAgents(page, pageSize);
const categories = await getCategories();
// const categories = await getCategories();
const notFeaturedAgents = await getNotFeaturedAgents();
// const notFeaturedAgents = await getNotFeaturedAgents();
return (
<div className={`flex flex-col gap-4 ${className}`}>
<div className="mb-4 flex justify-between">
<h3 className="text-lg font-semibold">Featured Agent Controls</h3>
<AdminAddFeaturedAgentDialog
categories={categories.unique_categories}
agents={notFeaturedAgents.items}
/>
</div>
<FeaturedAgentsTable
agents={agents.items}
globalActions={[
{
component: <Button>Remove</Button>,
action: async (rows) => {
"use server";
return await Sentry.withServerActionInstrumentation(
"removeFeaturedAgent",
{},
async () => {
const all = rows.map((row) => removeFeaturedAgent(row.id));
await Promise.all(all);
revalidatePath("/marketplace");
},
);
},
},
]}
/>
</div>
);
}
// return (
// <div className={`flex flex-col gap-4 ${className}`}>
// <div className="mb-4 flex justify-between">
// <h3 className="text-lg font-semibold">Featured Agent Controls</h3>
// <AdminAddFeaturedAgentDialog
// categories={categories.unique_categories}
// agents={notFeaturedAgents.items}
// />
// </div>
// <FeaturedAgentsTable
// agents={agents.items}
// globalActions={[
// {
// component: <Button>Remove</Button>,
// action: async (rows) => {
// "use server";
// return await Sentry.withServerActionInstrumentation(
// "removeFeaturedAgent",
// {},
// async () => {
// const all = rows.map((row) => removeFeaturedAgent(row.id));
// await Promise.all(all);
// revalidatePath("/marketplace");
// },
// );
// },
// },
// ]}
// />
// </div>
// );
// }

View File

@ -1,36 +1,36 @@
import { Agent } from "@/lib/marketplace-api";
import AdminMarketplaceCard from "./AdminMarketplaceCard";
import { ClipboardX } from "lucide-react";
// import { Agent } from "@/lib/marketplace-api";
// import AdminMarketplaceCard from "./AdminMarketplaceCard";
// import { ClipboardX } from "lucide-react";
export default function AdminMarketplaceAgentList({
agents,
className,
}: {
agents: Agent[];
className?: string;
}) {
if (agents.length === 0) {
return (
<div className={className}>
<h3 className="text-lg font-semibold">Agents to review</h3>
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<ClipboardX size={48} />
<p className="mt-4 text-lg font-semibold">No agents to review</p>
</div>
</div>
);
}
// export default function AdminMarketplaceAgentList({
// agents,
// className,
// }: {
// agents: Agent[];
// className?: string;
// }) {
// if (agents.length === 0) {
// return (
// <div className={className}>
// <h3 className="text-lg font-semibold">Agents to review</h3>
// <div className="flex flex-col items-center justify-center py-12 text-gray-500">
// <ClipboardX size={48} />
// <p className="mt-4 text-lg font-semibold">No agents to review</p>
// </div>
// </div>
// );
// }
return (
<div className={`flex flex-col gap-4 ${className}`}>
<div>
<h3 className="text-lg font-semibold">Agents to review</h3>
</div>
<div className="flex flex-col gap-4">
{agents.map((agent) => (
<AdminMarketplaceCard agent={agent} key={agent.id} />
))}
</div>
</div>
);
}
// return (
// <div className={`flex flex-col gap-4 ${className}`}>
// <div>
// <h3 className="text-lg font-semibold">Agents to review</h3>
// </div>
// <div className="flex flex-col gap-4">
// {agents.map((agent) => (
// <AdminMarketplaceCard agent={agent} key={agent.id} />
// ))}
// </div>
// </div>
// );
// }

View File

@ -1,113 +1,113 @@
"use client";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { approveAgent, rejectAgent } from "./actions";
import { Agent } from "@/lib/marketplace-api";
import Link from "next/link";
import { useState } from "react";
import { Input } from "@/components/ui/input";
// "use client";
// import { Card } from "@/components/ui/card";
// import { Button } from "@/components/ui/button";
// import { Badge } from "@/components/ui/badge";
// import { ScrollArea } from "@/components/ui/scroll-area";
// import { approveAgent, rejectAgent } from "./actions";
// import { Agent } from "@/lib/marketplace-api";
// import Link from "next/link";
// import { useState } from "react";
// import { Input } from "@/components/ui/input";
function AdminMarketplaceCard({ agent }: { agent: Agent }) {
const [isApproved, setIsApproved] = useState(false);
const [isRejected, setIsRejected] = useState(false);
const [comment, setComment] = useState("");
// function AdminMarketplaceCard({ agent }: { agent: Agent }) {
// const [isApproved, setIsApproved] = useState(false);
// const [isRejected, setIsRejected] = useState(false);
// const [comment, setComment] = useState("");
const approveAgentWithId = approveAgent.bind(
null,
agent.id,
agent.version,
comment,
);
const rejectAgentWithId = rejectAgent.bind(
null,
agent.id,
agent.version,
comment,
);
// const approveAgentWithId = approveAgent.bind(
// null,
// agent.id,
// agent.version,
// comment,
// );
// const rejectAgentWithId = rejectAgent.bind(
// null,
// agent.id,
// agent.version,
// comment,
// );
const handleApprove = async (e: React.FormEvent) => {
e.preventDefault();
await approveAgentWithId();
setIsApproved(true);
};
// const handleApprove = async (e: React.FormEvent) => {
// e.preventDefault();
// await approveAgentWithId();
// setIsApproved(true);
// };
const handleReject = async (e: React.FormEvent) => {
e.preventDefault();
await rejectAgentWithId();
setIsRejected(true);
};
// const handleReject = async (e: React.FormEvent) => {
// e.preventDefault();
// await rejectAgentWithId();
// setIsRejected(true);
// };
return (
<>
{!isApproved && !isRejected && (
<Card key={agent.id} className="m-3 flex h-[300px] flex-col p-4">
<div className="mb-2 flex items-start justify-between">
<Link
href={`/marketplace/${agent.id}`}
className="text-lg font-semibold hover:underline"
>
{agent.name}
</Link>
<Badge variant="outline">v{agent.version}</Badge>
</div>
<p className="mb-2 text-sm text-gray-500">by {agent.author}</p>
<ScrollArea className="flex-grow">
<p className="mb-2 text-sm text-gray-600">{agent.description}</p>
<div className="mb-2 flex flex-wrap gap-1">
{agent.categories.map((category) => (
<Badge key={category} variant="secondary">
{category}
</Badge>
))}
</div>
<div className="flex flex-wrap gap-1">
{agent.keywords.map((keyword) => (
<Badge key={keyword} variant="outline">
{keyword}
</Badge>
))}
</div>
</ScrollArea>
<div className="mb-2 flex justify-between text-xs text-gray-500">
<span>
Created: {new Date(agent.createdAt).toLocaleDateString()}
</span>
<span>
Updated: {new Date(agent.updatedAt).toLocaleDateString()}
</span>
</div>
<div className="mb-4 flex justify-between text-sm">
<span>👁 {agent.views}</span>
<span> {agent.downloads}</span>
</div>
<div className="mt-auto space-y-2">
<div className="flex justify-end space-x-2">
<Input
type="text"
placeholder="Add a comment (optional)"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
{!isRejected && (
<form onSubmit={handleReject}>
<Button variant="outline" type="submit">
Reject
</Button>
</form>
)}
{!isApproved && (
<form onSubmit={handleApprove}>
<Button type="submit">Approve</Button>
</form>
)}
</div>
</div>
</Card>
)}
</>
);
}
// return (
// <>
// {!isApproved && !isRejected && (
// <Card key={agent.id} className="m-3 flex h-[300px] flex-col p-4">
// <div className="mb-2 flex items-start justify-between">
// <Link
// href={`/marketplace/${agent.id}`}
// className="text-lg font-semibold hover:underline"
// >
// {agent.name}
// </Link>
// <Badge variant="outline">v{agent.version}</Badge>
// </div>
// <p className="mb-2 text-sm text-gray-500">by {agent.author}</p>
// <ScrollArea className="flex-grow">
// <p className="mb-2 text-sm text-gray-600">{agent.description}</p>
// <div className="mb-2 flex flex-wrap gap-1">
// {agent.categories.map((category) => (
// <Badge key={category} variant="secondary">
// {category}
// </Badge>
// ))}
// </div>
// <div className="flex flex-wrap gap-1">
// {agent.keywords.map((keyword) => (
// <Badge key={keyword} variant="outline">
// {keyword}
// </Badge>
// ))}
// </div>
// </ScrollArea>
// <div className="mb-2 flex justify-between text-xs text-gray-500">
// <span>
// Created: {new Date(agent.createdAt).toLocaleDateString()}
// </span>
// <span>
// Updated: {new Date(agent.updatedAt).toLocaleDateString()}
// </span>
// </div>
// <div className="mb-4 flex justify-between text-sm">
// <span>👁 {agent.views}</span>
// <span>⬇️ {agent.downloads}</span>
// </div>
// <div className="mt-auto space-y-2">
// <div className="flex justify-end space-x-2">
// <Input
// type="text"
// placeholder="Add a comment (optional)"
// value={comment}
// onChange={(e) => setComment(e.target.value)}
// />
// {!isRejected && (
// <form onSubmit={handleReject}>
// <Button variant="outline" type="submit">
// Reject
// </Button>
// </form>
// )}
// {!isApproved && (
// <form onSubmit={handleApprove}>
// <Button type="submit">Approve</Button>
// </form>
// )}
// </div>
// </div>
// </Card>
// )}
// </>
// );
// }
export default AdminMarketplaceCard;
// export default AdminMarketplaceCard;

View File

@ -1,114 +1,114 @@
"use client";
// "use client";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { DataTable } from "@/components/ui/data-table";
import { Agent } from "@/lib/marketplace-api";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown } from "lucide-react";
import { removeFeaturedAgent } from "./actions";
import { GlobalActions } from "@/components/ui/data-table";
// import { Button } from "@/components/ui/button";
// import { Checkbox } from "@/components/ui/checkbox";
// import { DataTable } from "@/components/ui/data-table";
// import { Agent } from "@/lib/marketplace-api";
// import { ColumnDef } from "@tanstack/react-table";
// import { ArrowUpDown } from "lucide-react";
// import { removeFeaturedAgent } from "./actions";
// import { GlobalActions } from "@/components/ui/data-table";
export const columns: ColumnDef<Agent>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
},
{
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
accessorKey: "name",
},
{
header: "Description",
accessorKey: "description",
},
{
header: "Categories",
accessorKey: "categories",
},
{
header: "Keywords",
accessorKey: "keywords",
},
{
header: "Downloads",
accessorKey: "downloads",
},
{
header: "Author",
accessorKey: "author",
},
{
header: "Version",
accessorKey: "version",
},
{
header: "actions",
cell: ({ row }) => {
const handleRemove = async () => {
await removeFeaturedAgentWithId();
};
// const handleEdit = async () => {
// console.log("edit");
// };
const removeFeaturedAgentWithId = removeFeaturedAgent.bind(
null,
row.original.id,
);
return (
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={handleRemove}>
Remove
</Button>
{/* <Button variant="outline" size="sm" onClick={handleEdit}>
Edit
</Button> */}
</div>
);
},
},
];
// export const columns: ColumnDef<Agent>[] = [
// {
// id: "select",
// header: ({ table }) => (
// <Checkbox
// checked={
// table.getIsAllPageRowsSelected() ||
// (table.getIsSomePageRowsSelected() && "indeterminate")
// }
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
// aria-label="Select all"
// />
// ),
// cell: ({ row }) => (
// <Checkbox
// checked={row.getIsSelected()}
// onCheckedChange={(value) => row.toggleSelected(!!value)}
// aria-label="Select row"
// />
// ),
// },
// {
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
// >
// Name
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// accessorKey: "name",
// },
// {
// header: "Description",
// accessorKey: "description",
// },
// {
// header: "Categories",
// accessorKey: "categories",
// },
// {
// header: "Keywords",
// accessorKey: "keywords",
// },
// {
// header: "Downloads",
// accessorKey: "downloads",
// },
// {
// header: "Author",
// accessorKey: "author",
// },
// {
// header: "Version",
// accessorKey: "version",
// },
// {
// header: "actions",
// cell: ({ row }) => {
// const handleRemove = async () => {
// await removeFeaturedAgentWithId();
// };
// // const handleEdit = async () => {
// // console.log("edit");
// // };
// const removeFeaturedAgentWithId = removeFeaturedAgent.bind(
// null,
// row.original.id,
// );
// return (
// <div className="flex justify-end gap-2">
// <Button variant="outline" size="sm" onClick={handleRemove}>
// Remove
// </Button>
// {/* <Button variant="outline" size="sm" onClick={handleEdit}>
// Edit
// </Button> */}
// </div>
// );
// },
// },
// ];
export default function FeaturedAgentsTable({
agents,
globalActions,
}: {
agents: Agent[];
globalActions: GlobalActions<Agent>[];
}) {
return (
<DataTable
columns={columns}
data={agents}
filterPlaceholder="Search agents..."
filterColumn="name"
globalActions={globalActions}
/>
);
}
// export default function FeaturedAgentsTable({
// agents,
// globalActions,
// }: {
// agents: Agent[];
// globalActions: GlobalActions<Agent>[];
// }) {
// return (
// <DataTable
// columns={columns}
// data={agents}
// filterPlaceholder="Search agents..."
// filterColumn="name"
// globalActions={globalActions}
// />
// );
// }

View File

@ -1,155 +1,155 @@
"use server";
import MarketplaceAPI from "@/lib/marketplace-api";
import ServerSideMarketplaceAPI from "@/lib/marketplace-api/server-client";
import { revalidatePath } from "next/cache";
import * as Sentry from "@sentry/nextjs";
import { checkAuth, createServerClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
// "use server";
// import MarketplaceAPI from "@/lib/marketplace-api";
// import ServerSideMarketplaceAPI from "@/lib/marketplace-api/server-client";
// import { revalidatePath } from "next/cache";
// import * as Sentry from "@sentry/nextjs";
// import { checkAuth, createServerClient } from "@/lib/supabase/server";
// import { redirect } from "next/navigation";
// import { createClient } from "@/lib/supabase/client";
export async function approveAgent(
agentId: string,
version: number,
comment: string,
) {
return await Sentry.withServerActionInstrumentation(
"approveAgent",
{},
async () => {
await checkAuth();
// export async function approveAgent(
// agentId: string,
// version: number,
// comment: string,
// ) {
// return await Sentry.withServerActionInstrumentation(
// "approveAgent",
// {},
// async () => {
// await checkAuth();
const api = new ServerSideMarketplaceAPI();
await api.approveAgentSubmission(agentId, version, comment);
console.debug(`Approving agent ${agentId}`);
revalidatePath("/marketplace");
},
);
}
// const api = new ServerSideMarketplaceAPI();
// await api.approveAgentSubmission(agentId, version, comment);
// console.debug(`Approving agent ${agentId}`);
// revalidatePath("/marketplace");
// },
// );
// }
export async function rejectAgent(
agentId: string,
version: number,
comment: string,
) {
return await Sentry.withServerActionInstrumentation(
"rejectAgent",
{},
async () => {
await checkAuth();
const api = new ServerSideMarketplaceAPI();
await api.rejectAgentSubmission(agentId, version, comment);
console.debug(`Rejecting agent ${agentId}`);
revalidatePath("/marketplace");
},
);
}
// export async function rejectAgent(
// agentId: string,
// version: number,
// comment: string,
// ) {
// return await Sentry.withServerActionInstrumentation(
// "rejectAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// await api.rejectAgentSubmission(agentId, version, comment);
// console.debug(`Rejecting agent ${agentId}`);
// revalidatePath("/marketplace");
// },
// );
// }
export async function getReviewableAgents() {
return await Sentry.withServerActionInstrumentation(
"getReviewableAgents",
{},
async () => {
await checkAuth();
const api = new ServerSideMarketplaceAPI();
return api.getAgentSubmissions();
},
);
}
// export async function getReviewableAgents() {
// return await Sentry.withServerActionInstrumentation(
// "getReviewableAgents",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// return api.getAgentSubmissions();
// },
// );
// }
export async function getFeaturedAgents(
page: number = 1,
pageSize: number = 10,
) {
return await Sentry.withServerActionInstrumentation(
"getFeaturedAgents",
{},
async () => {
await checkAuth();
const api = new ServerSideMarketplaceAPI();
const featured = await api.getFeaturedAgents(page, pageSize);
console.debug(`Getting featured agents ${featured.items.length}`);
return featured;
},
);
}
// export async function getFeaturedAgents(
// page: number = 1,
// pageSize: number = 10,
// ) {
// return await Sentry.withServerActionInstrumentation(
// "getFeaturedAgents",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// const featured = await api.getFeaturedAgents(page, pageSize);
// console.debug(`Getting featured agents ${featured.items.length}`);
// return featured;
// },
// );
// }
export async function getFeaturedAgent(agentId: string) {
return await Sentry.withServerActionInstrumentation(
"getFeaturedAgent",
{},
async () => {
await checkAuth();
const api = new ServerSideMarketplaceAPI();
const featured = await api.getFeaturedAgent(agentId);
console.debug(`Getting featured agent ${featured.agentId}`);
return featured;
},
);
}
// export async function getFeaturedAgent(agentId: string) {
// return await Sentry.withServerActionInstrumentation(
// "getFeaturedAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// const featured = await api.getFeaturedAgent(agentId);
// console.debug(`Getting featured agent ${featured.agentId}`);
// return featured;
// },
// );
// }
export async function addFeaturedAgent(
agentId: string,
categories: string[] = ["featured"],
) {
return await Sentry.withServerActionInstrumentation(
"addFeaturedAgent",
{},
async () => {
await checkAuth();
const api = new ServerSideMarketplaceAPI();
await api.addFeaturedAgent(agentId, categories);
console.debug(`Adding featured agent ${agentId}`);
revalidatePath("/marketplace");
},
);
}
// export async function addFeaturedAgent(
// agentId: string,
// categories: string[] = ["featured"],
// ) {
// return await Sentry.withServerActionInstrumentation(
// "addFeaturedAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// await api.addFeaturedAgent(agentId, categories);
// console.debug(`Adding featured agent ${agentId}`);
// revalidatePath("/marketplace");
// },
// );
// }
export async function removeFeaturedAgent(
agentId: string,
categories: string[] = ["featured"],
) {
return await Sentry.withServerActionInstrumentation(
"removeFeaturedAgent",
{},
async () => {
await checkAuth();
const api = new ServerSideMarketplaceAPI();
await api.removeFeaturedAgent(agentId, categories);
console.debug(`Removing featured agent ${agentId}`);
revalidatePath("/marketplace");
},
);
}
// export async function removeFeaturedAgent(
// agentId: string,
// categories: string[] = ["featured"],
// ) {
// return await Sentry.withServerActionInstrumentation(
// "removeFeaturedAgent",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// await api.removeFeaturedAgent(agentId, categories);
// console.debug(`Removing featured agent ${agentId}`);
// revalidatePath("/marketplace");
// },
// );
// }
export async function getCategories() {
return await Sentry.withServerActionInstrumentation(
"getCategories",
{},
async () => {
await checkAuth();
const api = new ServerSideMarketplaceAPI();
const categories = await api.getCategories();
console.debug(
`Getting categories ${categories.unique_categories.length}`,
);
return categories;
},
);
}
// export async function getCategories() {
// return await Sentry.withServerActionInstrumentation(
// "getCategories",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// const categories = await api.getCategories();
// console.debug(
// `Getting categories ${categories.unique_categories.length}`,
// );
// return categories;
// },
// );
// }
export async function getNotFeaturedAgents(
page: number = 1,
pageSize: number = 100,
) {
return await Sentry.withServerActionInstrumentation(
"getNotFeaturedAgents",
{},
async () => {
await checkAuth();
const api = new ServerSideMarketplaceAPI();
const agents = await api.getNotFeaturedAgents(page, pageSize);
console.debug(`Getting not featured agents ${agents.items.length}`);
return agents;
},
);
}
// export async function getNotFeaturedAgents(
// page: number = 1,
// pageSize: number = 100,
// ) {
// return await Sentry.withServerActionInstrumentation(
// "getNotFeaturedAgents",
// {},
// async () => {
// await checkAuth();
// const api = new ServerSideMarketplaceAPI();
// const agents = await api.getNotFeaturedAgents(page, pageSize);
// console.debug(`Getting not featured agents ${agents.items.length}`);
// return agents;
// },
// );
// }

View File

@ -96,19 +96,16 @@ export const AgentImportForm: React.FC<
name: values.agentName,
description: values.agentDescription,
is_active: !values.importAsTemplate,
is_template: values.importAsTemplate,
};
(values.importAsTemplate
? api.createTemplate(payload)
: api.createGraph(payload)
)
api
.createGraph(payload)
.then((response) => {
const qID = values.importAsTemplate ? "templateID" : "flowID";
const qID = "flowID";
window.location.href = `/build?${qID}=${response.id}`;
})
.catch((error) => {
const entity_type = values.importAsTemplate ? "template" : "agent";
const entity_type = "agent";
form.setError("root", {
message: `Could not create ${entity_type}: ${error}`,
});
@ -159,7 +156,6 @@ export const AgentImportForm: React.FC<
setAgentObject(agent);
form.setValue("agentName", agent.name);
form.setValue("agentDescription", agent.description);
form.setValue("importAsTemplate", agent.is_template);
} catch (error) {
console.error("Error loading agent file:", error);
}
@ -202,41 +198,6 @@ export const AgentImportForm: React.FC<
</FormItem>
)}
/>
<FormField
control={form.control}
name="importAsTemplate"
disabled={!agentObject}
render={({ field }) => (
<FormItem>
<FormLabel>Import as</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<span
className={
field.value ? "text-gray-400 dark:text-gray-600" : ""
}
>
Agent
</span>
<Switch
data-testid="import-as-template-switch"
disabled={field.disabled}
checked={field.value}
onCheckedChange={field.onChange}
/>
<span
className={
field.value ? "" : "text-gray-400 dark:text-gray-600"
}
>
Template
</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"

View File

@ -0,0 +1,115 @@
import * as React from "react";
import Image from "next/image";
import { PlayIcon } from "@radix-ui/react-icons";
import { Button } from "./Button";
const isValidVideoFile = (url: string): boolean => {
const videoExtensions = /\.(mp4|webm|ogg)$/i;
return videoExtensions.test(url);
};
const isValidVideoUrl = (url: string): boolean => {
const videoExtensions = /\.(mp4|webm|ogg)$/i;
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
return videoExtensions.test(url) || youtubeRegex.test(url);
};
const getYouTubeVideoId = (url: string) => {
const regExp =
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
const match = url.match(regExp);
return match && match[7].length === 11 ? match[7] : null;
};
interface AgentImageItemProps {
image: string;
index: number;
playingVideoIndex: number | null;
handlePlay: (index: number) => void;
handlePause: (index: number) => void;
}
export const AgentImageItem: React.FC<AgentImageItemProps> = React.memo(
({ image, index, playingVideoIndex, handlePlay, handlePause }) => {
const videoRef = React.useRef<HTMLVideoElement>(null);
React.useEffect(() => {
if (
playingVideoIndex !== index &&
videoRef.current &&
!videoRef.current.paused
) {
videoRef.current.pause();
}
}, [playingVideoIndex, index]);
const isVideoFile = isValidVideoFile(image);
return (
<div className="relative">
<div className="h-[15rem] overflow-hidden rounded-xl bg-[#a8a8a8] dark:bg-neutral-700 sm:h-[20rem] sm:w-full md:h-[25rem] lg:h-[30rem]">
{isValidVideoUrl(image) ? (
getYouTubeVideoId(image) ? (
<iframe
width="100%"
height="100%"
src={`https://www.youtube.com/embed/${getYouTubeVideoId(image)}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title="YouTube video player"
></iframe>
) : (
<div className="relative h-full w-full overflow-hidden">
<video
ref={videoRef}
className="absolute inset-0 h-full w-full object-cover"
controls
preload="metadata"
poster={`${image}#t=0.1`}
style={{ objectPosition: "center 25%" }}
onPlay={() => handlePlay(index)}
onPause={() => handlePause(index)}
autoPlay={false}
title="Video"
>
<source src={image} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
)
) : (
<div className="relative h-full w-full">
<Image
src={image}
alt="Image"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="rounded-xl object-cover"
/>
</div>
)}
</div>
{isVideoFile && playingVideoIndex !== index && (
<div className="absolute bottom-2 left-2 sm:bottom-3 sm:left-3 md:bottom-4 md:left-4 lg:bottom-[1.25rem] lg:left-[1.25rem]">
<Button
variant="default"
size="default"
onClick={() => {
if (videoRef.current) {
videoRef.current.play();
}
}}
>
<span className="pr-1 font-neue text-sm font-medium leading-6 tracking-tight text-[#272727] dark:text-neutral-200 sm:pr-2 sm:text-base sm:leading-7 md:text-lg md:leading-8 lg:text-xl lg:leading-9">
Play demo
</span>
<PlayIcon className="h-5 w-5 text-black dark:text-neutral-200 sm:h-6 sm:w-6 md:h-7 md:w-7" />
</Button>
</div>
)}
</div>
);
},
);
AgentImageItem.displayName = "AgentImageItem";

View File

@ -0,0 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react";
import { AgentImages } from "./AgentImages";
const meta = {
title: "AGPT UI/Agent Images",
component: AgentImages,
parameters: {
layout: {
center: true,
fullscreen: true,
padding: 0,
},
},
tags: ["autodocs"],
argTypes: {
images: { control: "object" },
},
} satisfies Meta<typeof AgentImages>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
images: [
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
"https://youtu.be/KWonAsyKF3g?si=JMibxlN_6OVo6LhJ",
"https://storage.googleapis.com/agpt-dev-website-media/DJINeo.mp4",
],
},
};
export const OnlyImages: Story = {
args: {
images: [
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
"https://upload.wikimedia.org/wikipedia/commons/c/c5/Big_buck_bunny_poster_big.jpg",
],
},
};
export const WithVideos: Story = {
args: {
images: [
"https://storage.googleapis.com/agpt-dev-website-media/DJINeo.mp4",
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
"https://youtu.be/KWonAsyKF3g?si=JMibxlN_6OVo6LhJ",
],
},
};
export const SingleItem: Story = {
args: {
images: [
"https://upload.wikimedia.org/wikipedia/commons/c/c5/Big_buck_bunny_poster_big.jpg",
],
},
};

View File

@ -0,0 +1,44 @@
"use client";
import * as React from "react";
import { AgentImageItem } from "./AgentImageItem";
interface AgentImagesProps {
images: string[];
}
export const AgentImages: React.FC<AgentImagesProps> = ({ images }) => {
const [playingVideoIndex, setPlayingVideoIndex] = React.useState<
number | null
>(null);
const handlePlay = React.useCallback((index: number) => {
setPlayingVideoIndex(index);
}, []);
const handlePause = React.useCallback(
(index: number) => {
if (playingVideoIndex === index) {
setPlayingVideoIndex(null);
}
},
[playingVideoIndex],
);
return (
<div className="w-full overflow-y-auto bg-white px-2 dark:bg-gray-800 lg:w-[56.25rem]">
<div className="space-y-4 sm:space-y-6 md:space-y-[1.875rem]">
{images.map((image, index) => (
<AgentImageItem
key={index}
image={image}
index={index}
playingVideoIndex={playingVideoIndex}
handlePlay={handlePlay}
handlePause={handlePause}
/>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,136 @@
import type { Meta, StoryObj } from "@storybook/react";
import { AgentInfo } from "./AgentInfo";
import { userEvent, within } from "@storybook/test";
const meta = {
title: "AGPT UI/Agent Info",
component: AgentInfo,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
onRunAgent: { action: "run agent clicked" },
name: { control: "text" },
creator: { control: "text" },
shortDescription: { control: "text" },
longDescription: { control: "text" },
rating: { control: "number", min: 0, max: 5, step: 0.1 },
runs: { control: "number" },
categories: { control: "object" },
lastUpdated: { control: "text" },
version: { control: "text" },
},
} satisfies Meta<typeof AgentInfo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
onRunAgent: () => console.log("Run agent clicked"),
name: "AI Video Generator",
creator: "Toran Richards",
shortDescription:
"Transform ideas into breathtaking images with this AI-powered Image Generator.",
longDescription: `Create Viral-Ready Content in Seconds! Transform trending topics into engaging videos with this cutting-edge AI Video Generator. Perfect for content creators, social media managers, and marketers looking to quickly produce high-quality content.
Key features include:
- Customizable video output
- 15+ pre-made templates
- Auto scene detection
- Smart text-to-speech
- Multiple export formats
- SEO-optimized suggestions`,
rating: 4.7,
runs: 1500,
categories: ["Video", "Content Creation", "Social Media"],
lastUpdated: "2 days ago",
version: "1.2.0",
},
};
export const LowRating: Story = {
args: {
...Default.args,
name: "Data Analyzer",
creator: "DataTech",
shortDescription:
"Analyze complex datasets with machine learning algorithms",
longDescription:
"A comprehensive data analysis tool that leverages machine learning to provide deep insights into your datasets. Currently in beta testing phase.",
rating: 2.7,
runs: 5000,
categories: ["Data Analysis", "Machine Learning"],
lastUpdated: "1 week ago",
version: "0.9.5",
},
};
export const HighRuns: Story = {
args: {
...Default.args,
name: "Code Assistant",
creator: "DevAI",
shortDescription:
"Get AI-powered coding help for various programming languages",
longDescription:
"An advanced AI coding assistant that supports multiple programming languages and frameworks. Features include code completion, refactoring suggestions, and bug detection.",
rating: 4.8,
runs: 1000000,
categories: ["Programming", "AI", "Developer Tools"],
lastUpdated: "1 day ago",
version: "2.1.3",
},
};
export const WithInteraction: Story = {
args: {
...Default.args,
name: "Task Planner",
creator: "Productivity AI",
shortDescription: "Plan and organize your tasks efficiently with AI",
longDescription:
"An intelligent task management system that helps you organize, prioritize, and complete your tasks more efficiently. Features smart scheduling and AI-powered suggestions.",
rating: 4.2,
runs: 50000,
categories: ["Productivity", "Task Management", "AI"],
lastUpdated: "3 days ago",
version: "1.5.2",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Test run agent button
const runButton = canvas.getByText("Run agent");
await userEvent.hover(runButton);
await userEvent.click(runButton);
// Test rating interaction
const ratingStars = canvas.getAllByLabelText(/Star Icon/);
await userEvent.hover(ratingStars[3]);
await userEvent.click(ratingStars[3]);
// Test category interaction
const category = canvas.getByText("Productivity");
await userEvent.hover(category);
await userEvent.click(category);
},
};
export const LongDescription: Story = {
args: {
...Default.args,
name: "AI Writing Assistant",
creator: "WordCraft AI",
shortDescription:
"Enhance your writing with our advanced AI-powered assistant.",
longDescription:
"It offers real-time suggestions for grammar, style, and tone, helps with research and fact-checking, and can even generate content ideas based on your input.",
rating: 4.7,
runs: 75000,
categories: ["Writing", "AI", "Content Creation"],
lastUpdated: "5 days ago",
version: "3.0.1",
},
};

View File

@ -0,0 +1,120 @@
"use client";
import * as React from "react";
import { IconPlay, IconStar, StarRatingIcons } from "@/components/ui/icons";
import Link from "next/link";
import { Separator } from "@/components/ui/separator";
interface AgentInfoProps {
name: string;
creator: string;
shortDescription: string;
longDescription: string;
rating: number;
runs: number;
categories: string[];
lastUpdated: string;
version: string;
}
export const AgentInfo: React.FC<AgentInfoProps> = ({
name,
creator,
shortDescription,
longDescription,
rating,
runs,
categories,
lastUpdated,
version,
}) => {
return (
<div className="w-full max-w-[396px] px-4 sm:px-6 lg:w-[396px] lg:px-0">
{/* Title */}
<div className="font-poppins mb-3 w-full text-2xl font-medium leading-normal text-neutral-900 dark:text-neutral-100 sm:text-3xl lg:mb-4 lg:text-[35px] lg:leading-10">
{name}
</div>
{/* Creator */}
<div className="mb-3 flex w-full items-center gap-1.5 lg:mb-4">
<div className="font-geist text-base font-normal text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl">
by
</div>
<div className="font-geist text-base font-medium text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl">
{creator}
</div>
</div>
{/* Short Description */}
<div className="font-geist mb-4 line-clamp-2 w-full text-base font-normal leading-normal text-neutral-600 dark:text-neutral-300 sm:text-lg lg:mb-6 lg:text-xl lg:leading-7">
{shortDescription}
</div>
{/* 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">
<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
</span>
</button>
</div>
{/* Rating and Runs */}
<div className="mb-4 flex w-full items-center justify-between lg:mb-6">
<div className="flex items-center gap-1.5 sm:gap-2">
<span className="font-geist whitespace-nowrap text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
{rating.toFixed(1)}
</span>
<div className="flex gap-0.5">{StarRatingIcons(rating)}</div>
</div>
<div className="font-geist whitespace-nowrap text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
{runs.toLocaleString()} runs
</div>
</div>
{/* Separator */}
<Separator className="mb-4 lg:mb-6" />
{/* Description Section */}
<div className="mb-4 w-full lg:mb-6">
<div className="mb-1.5 text-xs font-medium text-neutral-800 dark:text-neutral-200 sm:mb-2 sm:text-sm">
Description
</div>
<div className="font-geist w-full whitespace-pre-line text-sm font-normal text-neutral-600 dark:text-neutral-300 sm:text-base">
{longDescription}
</div>
</div>
{/* Categories */}
<div className="mb-4 flex w-full flex-col gap-1.5 sm:gap-2 lg:mb-6">
<div className="text-xs font-medium text-neutral-800 dark:text-neutral-200 sm:text-sm">
Categories
</div>
<div className="flex flex-wrap gap-1.5 sm:gap-2">
{categories.map((category, index) => (
<div
key={index}
className="whitespace-nowrap rounded-full border border-neutral-200 bg-white px-2 py-0.5 text-xs text-neutral-800 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 sm:px-3 sm:py-1 sm:text-sm"
>
{category}
</div>
))}
</div>
</div>
{/* Version History */}
<div className="flex w-full flex-col gap-0.5 sm:gap-1">
<div className="text-xs font-medium text-neutral-800 dark:text-neutral-200 sm:text-sm">
Version history
</div>
<div className="text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm">
Last updated {lastUpdated}
</div>
<div className="text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm">
Version {version}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,85 @@
import type { Meta, StoryObj } from "@storybook/react";
import { AgentTable } from "./AgentTable";
import { AgentTableRowProps } from "./AgentTableRow";
import { userEvent, within, expect } from "@storybook/test";
import { StatusType } from "./Status";
const meta: Meta<typeof AgentTable> = {
title: "AGPT UI/Agent Table",
component: AgentTable,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof AgentTable>;
const sampleAgents: AgentTableRowProps[] = [
{
id: "agent-1",
agentName: "Super Coder",
description: "An AI agent that writes clean, efficient code",
imageSrc:
"https://ddz4ak4pa3d19.cloudfront.net/cache/53/b2/53b2bc7d7900f0e1e60bf64ebf38032d.jpg",
dateSubmitted: "2023-05-15",
status: "approved",
runs: 1500,
rating: 4.8,
onEdit: () => console.log("Edit Super Coder"),
},
{
id: "agent-2",
agentName: "Data Analyzer",
description: "Processes and analyzes large datasets with ease",
imageSrc:
"https://ddz4ak4pa3d19.cloudfront.net/cache/40/f7/40f7bc97c952f8df0f9c88d29defe8d4.jpg",
dateSubmitted: "2023-05-10",
status: "awaiting_review",
runs: 1200,
rating: 4.5,
onEdit: () => console.log("Edit Data Analyzer"),
},
{
id: "agent-3",
agentName: "UI Designer",
description: "Creates beautiful and intuitive user interfaces",
imageSrc:
"https://ddz4ak4pa3d19.cloudfront.net/cache/14/9e/149ebb9014aa8c0097e72ed89845af0e.jpg",
dateSubmitted: "2023-05-05",
status: "draft",
runs: 800,
rating: 4.2,
onEdit: () => console.log("Edit UI Designer"),
},
];
export const Default: Story = {
args: {
agents: sampleAgents,
},
};
export const EmptyTable: Story = {
args: {
agents: [],
},
};
// Tests
export const InteractionTest: Story = {
...Default,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const editButtons = await canvas.findAllByText("Edit");
await userEvent.click(editButtons[0]);
// You would typically assert something here, but console.log is used in the mocked function
},
};
export const EmptyTableTest: Story = {
...EmptyTable,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const emptyMessage = canvas.getByText("No agents found");
expect(emptyMessage).toBeTruthy();
},
};

View File

@ -0,0 +1,107 @@
"use client";
import * as React from "react";
import { AgentTableRow, AgentTableRowProps } from "./AgentTableRow";
import { AgentTableCard } from "./AgentTableCard";
import { StoreSubmissionRequest } from "@/lib/autogpt-server-api/types";
export interface AgentTableProps {
agents: AgentTableRowProps[];
onEditSubmission: (submission: StoreSubmissionRequest) => void;
onDeleteSubmission: (submission_id: string) => void;
}
export const AgentTable: React.FC<AgentTableProps> = ({
agents,
onEditSubmission,
onDeleteSubmission,
}) => {
// Use state to track selected agents
const [selectedAgents, setSelectedAgents] = React.useState<Set<string>>(
new Set(),
);
// Handle select all checkbox
const handleSelectAll = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedAgents(new Set(agents.map((agent) => agent.id.toString())));
} else {
setSelectedAgents(new Set());
}
},
[agents],
);
return (
<div className="w-full">
{/* Table header - Hide on mobile */}
<div className="hidden flex-col md:flex">
<div className="border-t border-neutral-300 dark:border-neutral-700" />
<div className="flex items-center px-4 py-2">
<div className="flex items-center">
<div className="flex min-w-[120px] items-center">
<input
type="checkbox"
id="selectAllAgents"
aria-label="Select all agents"
className="mr-4 h-5 w-5 rounded border-2 border-neutral-400 dark:border-neutral-600"
checked={
selectedAgents.size === agents.length && agents.length > 0
}
onChange={handleSelectAll}
/>
<label
htmlFor="selectAllAgents"
className="text-sm font-medium text-neutral-800 dark:text-neutral-200"
>
Select all
</label>
</div>
</div>
<div className="ml-2 grid w-full grid-cols-[400px,150px,150px,100px,100px,50px] items-center">
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
Agent info
</div>
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
Date submitted
</div>
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
Status
</div>
<div className="text-right text-sm font-medium text-neutral-800 dark:text-neutral-200">
Runs
</div>
<div className="text-right text-sm font-medium text-neutral-800 dark:text-neutral-200">
Reviews
</div>
<div></div>
</div>
</div>
<div className="border-b border-neutral-300 dark:border-neutral-700" />
</div>
{/* Table body */}
{agents.length > 0 ? (
<div className="flex flex-col">
{agents.map((agent, index) => (
<div key={agent.id} className="md:block">
<AgentTableRow
{...agent}
onEditSubmission={onEditSubmission}
onDeleteSubmission={onDeleteSubmission}
/>
<div className="block md:hidden">
<AgentTableCard {...agent} />
</div>
</div>
))}
</div>
) : (
<div className="py-4 text-center font-['Geist'] text-base text-neutral-600 dark:text-neutral-400">
No agents available. Create your first agent to get started!
</div>
)}
</div>
);
};

View File

@ -0,0 +1,65 @@
import type { Meta, StoryObj } from "@storybook/react";
import { AgentTableCard } from "./AgentTableCard";
import { userEvent, within, expect } from "@storybook/test";
import { type StatusType } from "./Status";
const meta: Meta<typeof AgentTableCard> = {
title: "AGPT UI/Agent Table Card",
component: AgentTableCard,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof AgentTableCard>;
export const Default: Story = {
args: {
agentName: "Super Coder",
description: "An AI agent that writes clean, efficient code",
imageSrc:
"https://ddz4ak4pa3d19.cloudfront.net/cache/53/b2/53b2bc7d7900f0e1e60bf64ebf38032d.jpg",
dateSubmitted: "2023-05-15",
status: "ACTIVE" as StatusType,
runs: 1500,
rating: 4.8,
onEdit: () => console.log("Edit Super Coder"),
},
};
export const NoRating: Story = {
args: {
...Default.args,
rating: undefined,
},
};
export const NoRuns: Story = {
args: {
...Default.args,
runs: undefined,
},
};
export const InactiveAgent: Story = {
args: {
...Default.args,
status: "INACTIVE" as StatusType,
},
};
export const LongDescription: Story = {
args: {
...Default.args,
description:
"This is a very long description that should wrap to multiple lines. It contains detailed information about the agent and its capabilities.",
},
};
export const InteractionTest: Story = {
...Default,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const moreButton = canvas.getByRole("button");
await userEvent.click(moreButton);
},
};

View File

@ -0,0 +1,95 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { IconStarFilled, IconMore } from "@/components/ui/icons";
import { Status, StatusType } from "./Status";
export interface AgentTableCardProps {
agent_id: string;
agent_version: number;
agentName: string;
sub_heading: string;
description: string;
imageSrc: string[];
dateSubmitted: string;
status: StatusType;
runs: number;
rating: number;
id: number;
onEditSubmission: (submission: StoreSubmissionRequest) => void;
}
export const AgentTableCard: React.FC<AgentTableCardProps> = ({
agent_id,
agent_version,
agentName,
sub_heading,
description,
imageSrc,
dateSubmitted,
status,
runs,
rating,
id,
onEditSubmission,
}) => {
const onEdit = () => {
console.log("Edit agent", agentName);
onEditSubmission({
agent_id,
agent_version,
slug: "",
name: agentName,
sub_heading,
description,
image_urls: imageSrc,
categories: [],
});
};
return (
<div className="border-b border-neutral-300 p-4 dark:border-neutral-700">
<div className="flex gap-4">
<div className="relative h-[56px] w-[100px] overflow-hidden rounded-lg bg-[#d9d9d9] dark:bg-neutral-800">
<Image
src={imageSrc?.[0] ?? "/nada.png"}
alt={agentName}
fill
style={{ objectFit: "cover" }}
/>
</div>
<div className="flex-1">
<h3 className="text-[15px] font-medium text-neutral-800 dark:text-neutral-200">
{agentName}
</h3>
<p className="line-clamp-2 text-sm text-neutral-600 dark:text-neutral-400">
{description}
</p>
</div>
<button
onClick={onEdit}
className="h-fit rounded-full p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
<IconMore className="h-5 w-5 text-neutral-800 dark:text-neutral-200" />
</button>
</div>
<div className="mt-4 flex flex-wrap gap-4">
<Status status={status} />
<div className="text-sm text-neutral-600 dark:text-neutral-400">
{dateSubmitted}
</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400">
{runs.toLocaleString()} runs
</div>
<div className="flex items-center gap-1">
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
{rating.toFixed(1)}
</span>
<IconStarFilled className="h-4 w-4 text-neutral-800 dark:text-neutral-200" />
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,170 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { IconStarFilled, IconMore, IconEdit } from "@/components/ui/icons";
import { Status, StatusType } from "./Status";
import * as ContextMenu from "@radix-ui/react-context-menu";
import { TrashIcon } from "@radix-ui/react-icons";
import { StoreSubmissionRequest } from "@/lib/autogpt-server-api/types";
export interface AgentTableRowProps {
agent_id: string;
agent_version: number;
agentName: string;
sub_heading: string;
description: string;
imageSrc: string[];
date_submitted: string;
status: StatusType;
runs: number;
rating: number;
dateSubmitted: string;
id: number;
onEditSubmission: (submission: StoreSubmissionRequest) => void;
onDeleteSubmission: (submission_id: string) => void;
}
export const AgentTableRow: React.FC<AgentTableRowProps> = ({
agent_id,
agent_version,
agentName,
sub_heading,
description,
imageSrc,
dateSubmitted,
status,
runs,
rating,
id,
onEditSubmission,
onDeleteSubmission,
}) => {
// Create a unique ID for the checkbox
const checkboxId = `agent-${id}-checkbox`;
const handleEdit = React.useCallback(() => {
onEditSubmission({
agent_id,
agent_version,
slug: "",
name: agentName,
sub_heading,
description,
image_urls: imageSrc,
categories: [],
} as StoreSubmissionRequest);
}, [
agent_id,
agent_version,
agentName,
sub_heading,
description,
imageSrc,
onEditSubmission,
]);
const handleDelete = React.useCallback(() => {
onDeleteSubmission(agent_id);
}, [agent_id, onDeleteSubmission]);
return (
<div className="hidden items-center border-b border-neutral-300 px-4 py-4 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800 md:flex">
<div className="flex items-center">
<div className="flex items-center">
<input
type="checkbox"
id={checkboxId}
aria-label={`Select ${agentName}`}
className="mr-4 h-5 w-5 rounded border-2 border-neutral-400 dark:border-neutral-600"
/>
{/* Single label instead of multiple */}
<label htmlFor={checkboxId} className="sr-only">
Select {agentName}
</label>
</div>
</div>
<div className="grid w-full grid-cols-[minmax(400px,1fr),180px,140px,100px,100px,40px] items-center gap-4">
{/* Agent info column */}
<div className="flex items-center gap-4">
<div className="relative h-[70px] w-[125px] overflow-hidden rounded-[10px] bg-[#d9d9d9] dark:bg-neutral-700">
<Image
src={imageSrc?.[0] ?? "/nada.png"}
alt={agentName}
fill
style={{ objectFit: "cover" }}
/>
</div>
<div className="flex flex-col">
<h3 className="text-[15px] font-medium text-neutral-800 dark:text-neutral-200">
{agentName}
</h3>
<p className="line-clamp-2 text-sm text-neutral-600 dark:text-neutral-400">
{description}
</p>
</div>
</div>
{/* Date column */}
<div className="pl-14 text-sm text-neutral-600 dark:text-neutral-400">
{dateSubmitted}
</div>
{/* Status column */}
<div>
<Status status={status} />
</div>
{/* Runs column */}
<div className="text-right text-sm text-neutral-600 dark:text-neutral-400">
{runs?.toLocaleString() ?? "0"}
</div>
{/* Reviews column */}
<div className="text-right">
{rating ? (
<div className="flex items-center justify-end gap-1">
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
{rating.toFixed(1)}
</span>
<IconStarFilled className="h-4 w-4 text-neutral-800 dark:text-neutral-200" />
</div>
) : (
<span className="text-sm text-neutral-600 dark:text-neutral-400">
No reviews
</span>
)}
</div>
{/* Actions - Three dots menu */}
<div className="flex justify-end">
<ContextMenu.Root>
<ContextMenu.Trigger>
<button className="rounded-full p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700">
<IconMore className="h-5 w-5 text-neutral-800 dark:text-neutral-200" />
</button>
</ContextMenu.Trigger>
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800">
<ContextMenu.Item
onSelect={handleEdit}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<IconEdit className="mr-2 h-5 w-5 dark:text-gray-100" />
<span className="dark:text-gray-100">Edit</span>
</ContextMenu.Item>
<ContextMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" />
<ContextMenu.Item
onSelect={handleDelete}
className="flex cursor-pointer items-center rounded-md px-3 py-2 text-red-500 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<TrashIcon className="mr-2 h-5 w-5 text-red-500 dark:text-red-400" />
<span className="dark:text-red-400">Delete</span>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BecomeACreator } from "./BecomeACreator";
import { userEvent, within } from "@storybook/test";
const meta = {
title: "AGPT UI/Become A Creator",
component: BecomeACreator,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
title: { control: "text" },
heading: { control: "text" },
description: { control: "text" },
buttonText: { control: "text" },
onButtonClick: { action: "buttonClicked" },
},
} satisfies Meta<typeof BecomeACreator>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "Want to contribute?",
heading: "We're always looking for more Creators!",
description: "Join our ever-growing community of hackers and tinkerers",
buttonText: "Become a Creator",
onButtonClick: () => console.log("Button clicked"),
},
};
export const CustomText: Story = {
args: {
title: "Become a Creator Today!",
heading: "Join Our Creator Community",
description: "Share your ideas and build amazing AI agents with us",
buttonText: "Start Creating",
onButtonClick: () => console.log("Custom button clicked"),
},
};
export const LongDescription: Story = {
args: {
...Default.args,
description:
"Join our vibrant community of innovators, developers, and AI enthusiasts. Share your unique perspectives, collaborate on groundbreaking projects, and help shape the future of AI technology.",
},
};
export const WithInteraction: Story = {
args: {
...Default.args,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByText("Become a Creator");
await userEvent.click(button);
},
};

View File

@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import { PublishAgentPopout } from "./composite/PublishAgentPopout";
interface BecomeACreatorProps {
title?: string;
description?: string;
buttonText?: string;
onButtonClick?: () => void;
}
export const BecomeACreator: React.FC<BecomeACreatorProps> = ({
title = "Become a creator",
description = "Join a community where your AI creations can inspire, engage, and be downloaded by users around the world.",
buttonText = "Upload your agent",
onButtonClick,
}) => {
const handleButtonClick = () => {
onButtonClick?.();
console.log("Become A Creator clicked");
};
return (
<div className="relative mx-auto h-auto min-h-[300px] w-full max-w-[1360px] md:min-h-[400px] lg:h-[459px]">
{/* Top border */}
<div className="left-0 top-0 h-px w-full bg-gray-200 dark:bg-gray-700" />
{/* Title */}
<h2 className="mb-8 mt-6 text-2xl leading-7 text-neutral-800 dark:text-neutral-200">
{title}
</h2>
{/* Content Container */}
<div className="absolute left-1/2 top-1/2 w-full max-w-[900px] -translate-x-1/2 -translate-y-1/2 px-4 pt-16 text-center md:px-6 md:pt-10 lg:px-0">
<h2 className="font-poppins mb-6 text-3xl font-semibold leading-tight text-neutral-950 dark:text-neutral-50 md:mb-8 md:text-4xl md:leading-[1.2] lg:mb-12 lg:text-5xl lg:leading-[54px]">
Build AI agents and share
<br />
<span className="text-violet-600 dark:text-violet-400">
your
</span>{" "}
vision
</h2>
<p className="font-geist mx-auto mb-8 max-w-[90%] text-lg font-normal leading-relaxed text-neutral-700 dark:text-neutral-300 md:mb-10 md:text-xl md:leading-loose lg:mb-14 lg:text-2xl">
{description}
</p>
<PublishAgentPopout
trigger={
<button
onClick={handleButtonClick}
className="inline-flex h-[48px] cursor-pointer items-center justify-center rounded-[38px] bg-neutral-800 px-8 py-3 transition-colors hover:bg-neutral-700 dark:bg-neutral-700 dark:hover:bg-neutral-600 md:h-[56px] md:px-10 md:py-4 lg:h-[68px] lg:px-12 lg:py-5"
>
<span className="font-poppins whitespace-nowrap text-base font-medium leading-normal text-neutral-50 md:text-lg md:leading-relaxed lg:text-xl lg:leading-7">
{buttonText}
</span>
</button>
}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BreadCrumbs } from "./BreadCrumbs";
import { userEvent, within } from "@storybook/test";
const meta = {
title: "AGPT UI/BreadCrumbs",
component: BreadCrumbs,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
items: { control: "object" },
},
} satisfies Meta<typeof BreadCrumbs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items: [
{ name: "Home", link: "/" },
{ name: "Agents", link: "/agents" },
{ name: "SEO Optimizer", link: "/agents/seo-optimizer" },
],
},
};
export const SingleItem: Story = {
args: {
items: [{ name: "Home", link: "/" }],
},
};
export const LongPath: Story = {
args: {
items: [
{ name: "Home", link: "/" },
{ name: "Categories", link: "/categories" },
{ name: "AI Tools", link: "/categories/ai-tools" },
{ name: "Data Analysis", link: "/categories/ai-tools/data-analysis" },
{
name: "Data Analyzer",
link: "/categories/ai-tools/data-analysis/data-analyzer",
},
],
},
};
export const WithInteraction: Story = {
args: {
items: [
{ name: "Home", link: "/" },
{ name: "Agents", link: "/agents" },
{ name: "Task Planner", link: "/agents/task-planner" },
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const homeLink = canvas.getByText("Home");
await userEvent.hover(homeLink);
await userEvent.click(homeLink);
},
};
export const LongNames: Story = {
args: {
items: [
{ name: "Home", link: "/" },
{ name: "AI-Powered Writing Assistants", link: "/ai-writing-assistants" },
{
name: "Advanced Grammar and Style Checker",
link: "/ai-writing-assistants/grammar-style-checker",
},
],
},
};

View File

@ -0,0 +1,43 @@
import * as React from "react";
import Link from "next/link";
import { IconLeftArrow, IconRightArrow } from "@/components/ui/icons";
interface BreadcrumbItem {
name: string;
link: string;
}
interface BreadCrumbsProps {
items: BreadcrumbItem[];
}
export const BreadCrumbs: React.FC<BreadCrumbsProps> = ({ items }) => {
return (
<div className="flex items-center gap-4">
{/*
Commented out for now, but keeping until we have approval to remove
<button className="flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800">
<IconLeftArrow className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
</button>
<button className="flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800">
<IconRightArrow className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
</button> */}
<div className="flex h-auto min-h-[4.375rem] flex-wrap items-center justify-start gap-4 rounded-[5rem] bg-white dark:bg-neutral-900">
{items.map((item, index) => (
<React.Fragment key={index}>
<Link href={item.link}>
<span className="rounded py-1 pr-2 font-neue text-xl font-medium leading-9 tracking-tight text-[#272727] transition-colors duration-200 hover:text-gray-400 dark:text-neutral-100 dark:hover:text-gray-500">
{item.name}
</span>
</Link>
{index < items.length - 1 && (
<span className="font-['SF Pro'] text-center text-2xl font-normal text-black dark:text-neutral-100">
/
</span>
)}
</React.Fragment>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,220 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
import { userEvent, within, expect } from "@storybook/test";
const meta = {
title: "AGPT UI/Button",
component: Button,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: [
"default",
"destructive",
"outline",
"secondary",
"ghost",
"link",
],
},
size: {
control: "select",
options: ["default", "sm", "lg", "primary", "icon"],
},
disabled: {
control: "boolean",
},
asChild: {
control: "boolean",
},
children: {
control: "text",
},
onClick: { action: "clicked" },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: "Button",
},
};
export const Interactive: Story = {
args: {
children: "Interactive Button",
},
argTypes: {
onClick: { action: "clicked" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: /Interactive Button/i });
await userEvent.click(button);
await expect(button).toHaveFocus();
},
};
export const Variants: Story = {
render: (args) => (
<div className="flex flex-wrap gap-2">
<Button {...args} variant="default">
Default
</Button>
<Button {...args} variant="destructive">
Destructive
</Button>
<Button {...args} variant="outline">
Outline
</Button>
<Button {...args} variant="secondary">
Secondary
</Button>
<Button {...args} variant="ghost">
Ghost
</Button>
<Button {...args} variant="link">
Link
</Button>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const buttons = canvas.getAllByRole("button");
await expect(buttons).toHaveLength(6);
for (const button of buttons) {
await userEvent.hover(button);
await expect(button).toHaveAttribute(
"class",
expect.stringContaining("hover:"),
);
}
},
};
export const Sizes: Story = {
render: (args) => (
<div className="flex flex-wrap items-center gap-2">
<Button {...args} size="sm">
Small
</Button>
<Button {...args} size="default">
Default
</Button>
<Button {...args} size="lg">
Large
</Button>
<Button {...args} size="primary">
Primary
</Button>
<Button {...args} size="icon">
🚀
</Button>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const buttons = canvas.getAllByRole("button");
await expect(buttons).toHaveLength(5);
const sizeClasses = [
"h-8 px-3 py-1.5 text-xs",
"h-10 px-4 py-2 text-sm",
"h-12 px-5 py-2.5 text-lg",
"h-10 w-28",
"h-10 w-10",
];
for (let i = 0; i < buttons.length; i++) {
await expect(buttons[i]).toHaveAttribute(
"class",
expect.stringContaining(sizeClasses[i]),
);
}
},
};
export const Disabled: Story = {
args: {
children: "Disabled Button",
disabled: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: /Disabled Button/i });
await expect(button).toBeDisabled();
await expect(button).toHaveAttribute(
"class",
expect.stringContaining("disabled:opacity-50"),
);
await expect(button).not.toHaveFocus();
},
};
export const WithIcon: Story = {
args: {
children: (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
</svg>
Button with Icon
</>
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: /Button with Icon/i });
const icon = button.querySelector("svg");
await expect(icon).toBeInTheDocument();
await expect(button).toHaveTextContent("Button with Icon");
},
};
export const LoadingState: Story = {
args: {
children: "Loading...",
disabled: true,
},
render: (args) => (
<Button {...args}>
<svg
className="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
{args.children}
</Button>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button", { name: /Loading.../i });
await expect(button).toBeDisabled();
const spinner = button.querySelector("svg");
await expect(spinner).toHaveClass("animate-spin");
},
};

View File

@ -0,0 +1,69 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-[80px] text-xl font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-neutral-300 font-neue leading-9 tracking-tight",
{
variants: {
variant: {
default:
"bg-white border border-black/50 text-[#272727] hover:bg-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700",
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",
outline:
"bg-white border border-black/50 text-[#272727] 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",
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",
},
size: {
default:
"h-10 px-4 py-2 text-sm sm:h-12 sm:px-5 sm:py-2.5 sm:text-base md:h-14 md:px-6 md:py-3 md:text-lg lg:h-[4.375rem] lg:px-[1.625rem] lg:py-[0.4375rem] lg:text-xl",
sm: "h-8 px-3 py-1.5 text-xs sm:h-9 sm:px-3.5 sm:py-2 sm:text-sm md:h-10 md:px-4 md:py-2 md:text-base lg:h-[3.125rem] lg:px-[1.25rem] lg:py-[0.3125rem] lg:text-sm",
lg: "h-12 px-5 py-2.5 text-lg sm:h-14 sm:px-6 sm:py-3 sm:text-xl md:h-16 md:px-7 md:py-3.5 md:text-2xl lg:h-[5.625rem] lg:px-[2rem] lg:py-[0.5625rem] lg:text-2xl",
primary:
"h-10 w-28 sm:h-12 sm:w-32 md:h-[4.375rem] md:w-[11rem] lg:h-[3.125rem] lg:w-[7rem]",
icon: "h-10 w-10 sm:h-12 sm:w-12 md:h-14 md:w-14 lg:h-[4.375rem] lg:w-[4.375rem]",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
size?: "default" | "sm" | "lg" | "primary" | "icon";
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,78 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CreatorCard } from "./CreatorCard";
import { userEvent, within } from "@storybook/test";
const meta = {
title: "AGPT UI/Creator Card",
component: CreatorCard,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
creatorName: { control: "text" },
creatorImage: { control: "text" },
bio: { control: "text" },
agentsUploaded: { control: "number" },
onClick: { action: "clicked" },
avatarSrc: { control: "text" },
},
} satisfies Meta<typeof CreatorCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
creatorName: "John Doe",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
bio: "AI enthusiast and developer with a passion for creating innovative agents.",
agentsUploaded: 15,
onClick: () => console.log("Default CreatorCard clicked"),
avatarSrc: "https://github.com/shadcn.png",
},
};
export const NewCreator: Story = {
args: {
creatorName: "Jane Smith",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
bio: "Excited to start my journey in AI agent development!",
agentsUploaded: 1,
onClick: () => console.log("NewCreator CreatorCard clicked"),
avatarSrc: "https://example.com/avatar2.jpg",
},
};
export const ExperiencedCreator: Story = {
args: {
creatorName: "Alex Johnson",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
bio: "Veteran AI researcher with a focus on natural language processing and machine learning.",
agentsUploaded: 50,
onClick: () => console.log("ExperiencedCreator CreatorCard clicked"),
avatarSrc: "https://example.com/avatar3.jpg",
},
};
export const WithInteraction: Story = {
args: {
creatorName: "Sam Brown",
creatorImage:
"https://framerusercontent.com/images/KCIpxr9f97EGJgpaoqnjKsrOPwI.jpg",
bio: "Exploring the frontiers of AI and its applications in everyday life.",
agentsUploaded: 30,
onClick: () => console.log("WithInteraction CreatorCard clicked"),
avatarSrc: "https://example.com/avatar4.jpg",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const creatorCard = canvas.getByText("Sam Brown");
await userEvent.hover(creatorCard);
await userEvent.click(creatorCard);
},
};

View File

@ -0,0 +1,66 @@
import * as React from "react";
import Image from "next/image";
const BACKGROUND_COLORS = [
"bg-amber-100 dark:bg-amber-800", // #fef3c7 / #92400e
"bg-violet-100 dark:bg-violet-800", // #ede9fe / #5b21b6
"bg-green-100 dark:bg-green-800", // #dcfce7 / #065f46
"bg-blue-100 dark:bg-blue-800", // #dbeafe / #1e3a8a
];
interface CreatorCardProps {
creatorName: string;
creatorImage: string;
bio: string;
agentsUploaded: number;
onClick: () => void;
index: number;
}
export const CreatorCard: React.FC<CreatorCardProps> = ({
creatorName,
creatorImage,
bio,
agentsUploaded,
onClick,
index,
}) => {
const backgroundColor = BACKGROUND_COLORS[index % BACKGROUND_COLORS.length];
return (
<div
className={`h-[264px] w-full px-[18px] pb-5 pt-6 ${backgroundColor} inline-flex cursor-pointer flex-col items-start justify-start gap-3.5 rounded-[26px] transition-all duration-200 hover:brightness-95`}
onClick={onClick}
data-testid="creator-card"
>
<div className="relative h-[64px] w-[64px]">
<div className="absolute inset-0 overflow-hidden rounded-full">
{creatorImage ? (
<Image
src={creatorImage}
alt={creatorName}
width={64}
height={64}
className="h-full w-full object-cover"
priority
/>
) : (
<div className="h-full w-full bg-neutral-300 dark:bg-neutral-600" />
)}
</div>
</div>
<div className="flex flex-col gap-2">
<h3 className="font-poppins text-2xl font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
{creatorName}
</h3>
<p className="font-geist text-sm font-normal leading-normal text-neutral-600 dark:text-neutral-400">
{bio}
</p>
<div className="font-geist text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
{agentsUploaded} agents
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,55 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CreatorInfoCard } from "./CreatorInfoCard";
const meta = {
title: "AGPT UI/Creator Info Card",
component: CreatorInfoCard,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
username: { control: "text" },
handle: { control: "text" },
avatarSrc: { control: "text" },
categories: { control: "object" },
averageRating: { control: "number", min: 0, max: 5, step: 0.1 },
totalRuns: { control: "number" },
},
} satisfies Meta<typeof CreatorInfoCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
username: "SignificantGravitas",
handle: "oliviagrace1421",
avatarSrc: "https://github.com/shadcn.png",
categories: ["Entertainment", "Business"],
averageRating: 4.7,
totalRuns: 1500,
},
};
export const NewCreator: Story = {
args: {
username: "AI Enthusiast",
handle: "ai_newbie",
avatarSrc: "https://example.com/avatar2.jpg",
categories: ["AI", "Technology"],
averageRating: 0,
totalRuns: 0,
},
};
export const ExperiencedCreator: Story = {
args: {
username: "Tech Master",
handle: "techmaster",
avatarSrc: "https://example.com/avatar3.jpg",
categories: ["AI", "Development", "Education"],
averageRating: 4.9,
totalRuns: 50000,
},
};

View File

@ -0,0 +1,105 @@
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { StarRatingIcons } from "@/components/ui/icons";
interface CreatorInfoCardProps {
username: string;
handle: string;
avatarSrc: string;
categories: string[];
averageRating: number;
totalRuns: number;
}
export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
username,
handle,
avatarSrc,
categories,
averageRating,
totalRuns,
}) => {
return (
<div
className="inline-flex h-auto min-h-[500px] w-full max-w-[440px] flex-col items-start justify-between rounded-[26px] bg-violet-100 p-4 dark:bg-violet-900 sm:h-[632px] sm:w-[440px] sm:p-6"
role="article"
aria-label={`Creator profile for ${username}`}
>
<div className="flex w-full flex-col items-start justify-start gap-3.5 sm:h-[218px]">
<Avatar className="h-[100px] w-[100px] sm:h-[130px] sm:w-[130px]">
<AvatarImage src={avatarSrc} alt={`${username}'s avatar`} />
<AvatarFallback className="h-[100px] w-[100px] sm:h-[130px] sm:w-[130px]">
{username.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex w-full flex-col items-start justify-start gap-1.5">
<div className="font-poppins w-full text-2xl font-medium leading-8 text-neutral-900 dark:text-neutral-100 sm:text-[35px] sm:leading-10">
{username}
</div>
<div className="w-full font-neue text-lg font-normal leading-6 text-neutral-800 dark:text-neutral-200 sm:text-xl sm:leading-7">
@{handle}
</div>
</div>
</div>
<div className="my-4 flex w-full flex-col items-start justify-start gap-6 sm:gap-[50px]">
<div className="flex w-full flex-col items-start justify-start gap-3">
<div className="h-px w-full bg-neutral-700 dark:bg-neutral-300" />
<div className="flex flex-col items-start justify-start gap-2.5">
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Top categories
</div>
<div
className="flex flex-wrap items-center gap-2.5"
role="list"
aria-label="Categories"
>
{categories.map((category, index) => (
<div
key={index}
className="flex items-center justify-center gap-2.5 rounded-[34px] border border-neutral-600 px-5 py-3 dark:border-neutral-400"
role="listitem"
>
<div className="font-neue text-base font-normal leading-normal text-neutral-800 dark:text-neutral-200">
{category}
</div>
</div>
))}
</div>
</div>
</div>
<div className="flex w-full flex-col items-start justify-start gap-3">
<div className="h-px w-full bg-neutral-700 dark:bg-neutral-300" />
<div className="flex w-full flex-col items-start justify-between gap-4 sm:flex-row sm:gap-0">
<div className="flex w-full flex-col items-start justify-start gap-2.5 sm:w-[164px]">
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Average rating
</div>
<div className="inline-flex items-center gap-2">
<div className="font-neue text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
{averageRating.toFixed(1)}
</div>
<div
className="flex items-center gap-px"
role="img"
aria-label={`Rating: ${averageRating} out of 5 stars`}
>
{StarRatingIcons(averageRating)}
</div>
</div>
</div>
<div className="flex w-full flex-col items-start justify-start gap-2.5 sm:w-[164px]">
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Number of runs
</div>
<div className="font-neue text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
{new Intl.NumberFormat().format(totalRuns)} runs
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,71 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CreatorLinks } from "./CreatorLinks";
const meta = {
title: "AGPT UI/Creator Links",
component: CreatorLinks,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
links: {
control: "object",
description: "Object containing various social and web links",
},
},
} satisfies Meta<typeof CreatorLinks>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
links: {
website: "https://example.com",
linkedin: "https://linkedin.com/in/johndoe",
github: "https://github.com/johndoe",
other: ["https://twitter.com/johndoe", "https://medium.com/@johndoe"],
},
},
};
export const WebsiteOnly: Story = {
args: {
links: {
website: "https://example.com",
},
},
};
export const SocialLinks: Story = {
args: {
links: {
linkedin: "https://linkedin.com/in/janedoe",
github: "https://github.com/janedoe",
other: ["https://twitter.com/janedoe"],
},
},
};
export const NoLinks: Story = {
args: {
links: {},
},
};
export const MultipleOtherLinks: Story = {
args: {
links: {
website: "https://example.com",
linkedin: "https://linkedin.com/in/creator",
github: "https://github.com/creator",
other: [
"https://twitter.com/creator",
"https://medium.com/@creator",
"https://youtube.com/@creator",
"https://tiktok.com/@creator",
],
},
},
};

View File

@ -0,0 +1,43 @@
import * as React from "react";
import { getIconForSocial } from "@/components/ui/icons";
interface CreatorLinksProps {
links: string[];
}
export const CreatorLinks: React.FC<CreatorLinksProps> = ({ links }) => {
if (!links || links.length === 0) {
return null;
}
const renderLinkButton = (url: string) => (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex min-w-[200px] flex-1 items-center justify-between rounded-[34px] border border-neutral-600 px-5 py-3 dark:border-neutral-400"
>
<div className="font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
{new URL(url).hostname.replace("www.", "")}
</div>
<div className="relative h-6 w-6">
{getIconForSocial(url, {
className: "h-6 w-6 text-neutral-800 dark:text-neutral-200",
})}
</div>
</a>
);
return (
<div className="flex flex-col items-start justify-start gap-4">
<div className="font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Other links
</div>
<div className="flex w-full flex-wrap gap-3">
{links.map((link, index) => (
<React.Fragment key={index}>{renderLinkButton(link)}</React.Fragment>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,43 @@
import type { Meta, StoryObj } from "@storybook/react";
import CreditsCard from "./CreditsCard";
import { userEvent, within } from "@storybook/test";
const meta: Meta<typeof CreditsCard> = {
title: "AGPT UI/Credits Card",
component: CreditsCard,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof CreditsCard>;
export const Default: Story = {
args: {
credits: 0,
},
};
export const SmallNumber: Story = {
args: {
credits: 10,
},
};
export const LargeNumber: Story = {
args: {
credits: 1000000,
},
};
export const InteractionTest: Story = {
args: {
credits: 100,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const refreshButton = canvas.getByRole("button", {
name: /refresh credits/i,
});
await userEvent.click(refreshButton);
},
};

View File

@ -0,0 +1,53 @@
"use client";
import { IconRefresh } from "@/components/ui/icons";
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import { useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface CreditsCardProps {
credits: number;
}
const CreditsCard = ({ credits }: CreditsCardProps) => {
const [currentCredits, setCurrentCredits] = useState(credits);
const api = new AutoGPTServerAPI();
const onRefresh = async () => {
const { credits } = await api.getUserCredit("credits-card");
setCurrentCredits(credits);
};
return (
<div className="inline-flex h-[60px] items-center gap-2.5 rounded-2xl bg-neutral-200 p-4 dark:bg-neutral-800">
<div className="flex items-center gap-0.5">
<span className="p-ui-semibold text-base leading-7 text-neutral-900 dark:text-neutral-50">
{currentCredits.toLocaleString()}
</span>
<span className="p-ui pl-1 text-base leading-7 text-neutral-900 dark:text-neutral-50">
credits
</span>
</div>
<Tooltip key="RefreshCredits" delayDuration={500}>
<TooltipTrigger asChild>
<button
onClick={onRefresh}
className="h-6 w-6 transition-colors hover:text-neutral-700 dark:hover:text-neutral-300"
aria-label="Refresh credits"
>
<IconRefresh className="h-6 w-6" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh credits</p>
</TooltipContent>
</Tooltip>
</div>
);
};
export default CreditsCard;

Some files were not shown because too many files have changed in this diff Show More