Merge branch 'dev' into kpczerwinski/secrt-1012-mvp-implement-top-up-flow

This commit is contained in:
Krzysztof Czerwinski 2025-01-03 16:01:39 +01:00
commit c6eaa580dd
99 changed files with 3199 additions and 980 deletions

View File

@ -31,6 +31,7 @@ class RedisKeyedMutex:
try: try:
yield yield
finally: finally:
if lock.locked():
lock.release() lock.release()
def acquire(self, key: Any) -> "RedisLock": def acquire(self, key: Any) -> "RedisLock":
@ -45,7 +46,7 @@ class RedisKeyedMutex:
return lock return lock
def release(self, key: Any): def release(self, key: Any):
if lock := self.locks.get(key): if (lock := self.locks.get(key)) and lock.locked() and lock.owned():
lock.release() lock.release()
def release_all_locks(self): def release_all_locks(self):

View File

@ -0,0 +1,59 @@
from pydantic import BaseModel
from backend.data.block import (
Block,
BlockCategory,
BlockManualWebhookConfig,
BlockOutput,
BlockSchema,
)
from backend.data.model import SchemaField
from backend.integrations.webhooks.compass import CompassWebhookType
class Transcription(BaseModel):
text: str
speaker: str
end: float
start: float
duration: float
class TranscriptionDataModel(BaseModel):
date: str
transcription: str
transcriptions: list[Transcription]
class CompassAITriggerBlock(Block):
class Input(BlockSchema):
payload: TranscriptionDataModel = SchemaField(hidden=True)
class Output(BlockSchema):
transcription: str = SchemaField(
description="The contents of the compass transcription."
)
def __init__(self):
super().__init__(
id="9464a020-ed1d-49e1-990f-7f2ac924a2b7",
description="This block will output the contents of the compass transcription.",
categories={BlockCategory.HARDWARE},
input_schema=CompassAITriggerBlock.Input,
output_schema=CompassAITriggerBlock.Output,
webhook_config=BlockManualWebhookConfig(
provider="compass",
webhook_type=CompassWebhookType.TRANSCRIPTION,
),
test_input=[
{"input": "Hello, World!"},
{"input": "Hello, World!", "data": "Existing Data"},
],
# test_output=[
# ("output", "Hello, World!"), # No data provided, so trigger is returned
# ("output", "Existing Data"), # Data is provided, so data is returned.
# ],
)
def run(self, input_data: Input, **kwargs) -> BlockOutput:
yield "transcription", input_data.payload.transcription

View File

@ -0,0 +1,87 @@
from typing import List, Optional
from pydantic import BaseModel
from backend.blocks.exa._auth import (
ExaCredentials,
ExaCredentialsField,
ExaCredentialsInput,
)
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
class ContentRetrievalSettings(BaseModel):
text: Optional[dict] = SchemaField(
description="Text content settings",
default={"maxCharacters": 1000, "includeHtmlTags": False},
advanced=True,
)
highlights: Optional[dict] = SchemaField(
description="Highlight settings",
default={
"numSentences": 3,
"highlightsPerUrl": 3,
"query": "",
},
advanced=True,
)
summary: Optional[dict] = SchemaField(
description="Summary settings",
default={"query": ""},
advanced=True,
)
class ExaContentsBlock(Block):
class Input(BlockSchema):
credentials: ExaCredentialsInput = ExaCredentialsField()
ids: List[str] = SchemaField(
description="Array of document IDs obtained from searches",
)
contents: ContentRetrievalSettings = SchemaField(
description="Content retrieval settings",
default=ContentRetrievalSettings(),
advanced=True,
)
class Output(BlockSchema):
results: list = SchemaField(
description="List of document contents",
default=[],
)
def __init__(self):
super().__init__(
id="c52be83f-f8cd-4180-b243-af35f986b461",
description="Retrieves document contents using Exa's contents API",
categories={BlockCategory.SEARCH},
input_schema=ExaContentsBlock.Input,
output_schema=ExaContentsBlock.Output,
)
def run(
self, input_data: Input, *, credentials: ExaCredentials, **kwargs
) -> BlockOutput:
url = "https://api.exa.ai/contents"
headers = {
"Content-Type": "application/json",
"x-api-key": credentials.api_key.get_secret_value(),
}
payload = {
"ids": input_data.ids,
"text": input_data.contents.text,
"highlights": input_data.contents.highlights,
"summary": input_data.contents.summary,
}
try:
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
yield "results", data.get("results", [])
except Exception as e:
yield "error", str(e)
yield "results", []

View File

@ -0,0 +1,54 @@
from typing import Optional
from pydantic import BaseModel
from backend.data.model import SchemaField
class TextSettings(BaseModel):
max_characters: int = SchemaField(
default=1000,
description="Maximum number of characters to return",
placeholder="1000",
)
include_html_tags: bool = SchemaField(
default=False,
description="Whether to include HTML tags in the text",
placeholder="False",
)
class HighlightSettings(BaseModel):
num_sentences: int = SchemaField(
default=3,
description="Number of sentences per highlight",
placeholder="3",
)
highlights_per_url: int = SchemaField(
default=3,
description="Number of highlights per URL",
placeholder="3",
)
class SummarySettings(BaseModel):
query: Optional[str] = SchemaField(
default="",
description="Query string for summarization",
placeholder="Enter query",
)
class ContentSettings(BaseModel):
text: TextSettings = SchemaField(
default=TextSettings(),
description="Text content settings",
)
highlights: HighlightSettings = SchemaField(
default=HighlightSettings(),
description="Highlight settings",
)
summary: SummarySettings = SchemaField(
default=SummarySettings(),
description="Summary settings",
)

View File

@ -1,84 +1,76 @@
from datetime import datetime from datetime import datetime
from typing import List from typing import List
from pydantic import BaseModel
from backend.blocks.exa._auth import ( from backend.blocks.exa._auth import (
ExaCredentials, ExaCredentials,
ExaCredentialsField, ExaCredentialsField,
ExaCredentialsInput, ExaCredentialsInput,
) )
from backend.blocks.exa.helpers import ContentSettings
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField from backend.data.model import SchemaField
from backend.util.request import requests from backend.util.request import requests
class ContentSettings(BaseModel):
text: dict = SchemaField(
description="Text content settings",
default={"maxCharacters": 1000, "includeHtmlTags": False},
)
highlights: dict = SchemaField(
description="Highlight settings",
default={"numSentences": 3, "highlightsPerUrl": 3},
)
summary: dict = SchemaField(
description="Summary settings",
default={"query": ""},
)
class ExaSearchBlock(Block): class ExaSearchBlock(Block):
class Input(BlockSchema): class Input(BlockSchema):
credentials: ExaCredentialsInput = ExaCredentialsField() credentials: ExaCredentialsInput = ExaCredentialsField()
query: str = SchemaField(description="The search query") query: str = SchemaField(description="The search query")
useAutoprompt: bool = SchemaField( use_auto_prompt: bool = SchemaField(
description="Whether to use autoprompt", description="Whether to use autoprompt",
default=True, default=True,
advanced=True,
) )
type: str = SchemaField( type: str = SchemaField(
description="Type of search", description="Type of search",
default="", default="",
advanced=True,
) )
category: str = SchemaField( category: str = SchemaField(
description="Category to search within", description="Category to search within",
default="", default="",
advanced=True,
) )
numResults: int = SchemaField( number_of_results: int = SchemaField(
description="Number of results to return", description="Number of results to return",
default=10, default=10,
advanced=True,
) )
includeDomains: List[str] = SchemaField( include_domains: List[str] = SchemaField(
description="Domains to include in search", description="Domains to include in search",
default=[], default=[],
) )
excludeDomains: List[str] = SchemaField( exclude_domains: List[str] = SchemaField(
description="Domains to exclude from search", description="Domains to exclude from search",
default=[], default=[],
advanced=True,
) )
startCrawlDate: datetime = SchemaField( start_crawl_date: datetime = SchemaField(
description="Start date for crawled content", description="Start date for crawled content",
) )
endCrawlDate: datetime = SchemaField( end_crawl_date: datetime = SchemaField(
description="End date for crawled content", description="End date for crawled content",
) )
startPublishedDate: datetime = SchemaField( start_published_date: datetime = SchemaField(
description="Start date for published content", description="Start date for published content",
) )
endPublishedDate: datetime = SchemaField( end_published_date: datetime = SchemaField(
description="End date for published content", description="End date for published content",
) )
includeText: List[str] = SchemaField( include_text: List[str] = SchemaField(
description="Text patterns to include", description="Text patterns to include",
default=[], default=[],
advanced=True,
) )
excludeText: List[str] = SchemaField( exclude_text: List[str] = SchemaField(
description="Text patterns to exclude", description="Text patterns to exclude",
default=[], default=[],
advanced=True,
) )
contents: ContentSettings = SchemaField( contents: ContentSettings = SchemaField(
description="Content retrieval settings", description="Content retrieval settings",
default=ContentSettings(), default=ContentSettings(),
advanced=True,
) )
class Output(BlockSchema): class Output(BlockSchema):
@ -107,44 +99,38 @@ class ExaSearchBlock(Block):
payload = { payload = {
"query": input_data.query, "query": input_data.query,
"useAutoprompt": input_data.useAutoprompt, "useAutoprompt": input_data.use_auto_prompt,
"numResults": input_data.numResults, "numResults": input_data.number_of_results,
"contents": { "contents": input_data.contents.dict(),
"text": {"maxCharacters": 1000, "includeHtmlTags": False}, }
"highlights": {
"numSentences": 3, date_field_mapping = {
"highlightsPerUrl": 3, "start_crawl_date": "startCrawlDate",
}, "end_crawl_date": "endCrawlDate",
"summary": {"query": ""}, "start_published_date": "startPublishedDate",
}, "end_published_date": "endPublishedDate",
} }
# Add dates if they exist # Add dates if they exist
date_fields = [ for input_field, api_field in date_field_mapping.items():
"startCrawlDate", value = getattr(input_data, input_field, None)
"endCrawlDate",
"startPublishedDate",
"endPublishedDate",
]
for field in date_fields:
value = getattr(input_data, field, None)
if value: if value:
payload[field] = value.strftime("%Y-%m-%dT%H:%M:%S.000Z") payload[api_field] = value.strftime("%Y-%m-%dT%H:%M:%S.000Z")
optional_field_mapping = {
"type": "type",
"category": "category",
"include_domains": "includeDomains",
"exclude_domains": "excludeDomains",
"include_text": "includeText",
"exclude_text": "excludeText",
}
# Add other fields # Add other fields
optional_fields = [ for input_field, api_field in optional_field_mapping.items():
"type", value = getattr(input_data, input_field)
"category",
"includeDomains",
"excludeDomains",
"includeText",
"excludeText",
]
for field in optional_fields:
value = getattr(input_data, field)
if value: # Only add non-empty values if value: # Only add non-empty values
payload[field] = value payload[api_field] = value
try: try:
response = requests.post(url, headers=headers, json=payload) response = requests.post(url, headers=headers, json=payload)

View File

@ -0,0 +1,128 @@
from datetime import datetime
from typing import Any, List
from backend.blocks.exa._auth import (
ExaCredentials,
ExaCredentialsField,
ExaCredentialsInput,
)
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
from backend.util.request import requests
from .helpers import ContentSettings
class ExaFindSimilarBlock(Block):
class Input(BlockSchema):
credentials: ExaCredentialsInput = ExaCredentialsField()
url: str = SchemaField(
description="The url for which you would like to find similar links"
)
number_of_results: int = SchemaField(
description="Number of results to return",
default=10,
advanced=True,
)
include_domains: List[str] = SchemaField(
description="Domains to include in search",
default=[],
advanced=True,
)
exclude_domains: List[str] = SchemaField(
description="Domains to exclude from search",
default=[],
advanced=True,
)
start_crawl_date: datetime = SchemaField(
description="Start date for crawled content",
)
end_crawl_date: datetime = SchemaField(
description="End date for crawled content",
)
start_published_date: datetime = SchemaField(
description="Start date for published content",
)
end_published_date: datetime = SchemaField(
description="End date for published content",
)
include_text: List[str] = SchemaField(
description="Text patterns to include (max 1 string, up to 5 words)",
default=[],
advanced=True,
)
exclude_text: List[str] = SchemaField(
description="Text patterns to exclude (max 1 string, up to 5 words)",
default=[],
advanced=True,
)
contents: ContentSettings = SchemaField(
description="Content retrieval settings",
default=ContentSettings(),
advanced=True,
)
class Output(BlockSchema):
results: List[Any] = SchemaField(
description="List of similar documents with title, URL, published date, author, and score",
default=[],
)
def __init__(self):
super().__init__(
id="5e7315d1-af61-4a0c-9350-7c868fa7438a",
description="Finds similar links using Exa's findSimilar API",
categories={BlockCategory.SEARCH},
input_schema=ExaFindSimilarBlock.Input,
output_schema=ExaFindSimilarBlock.Output,
)
def run(
self, input_data: Input, *, credentials: ExaCredentials, **kwargs
) -> BlockOutput:
url = "https://api.exa.ai/findSimilar"
headers = {
"Content-Type": "application/json",
"x-api-key": credentials.api_key.get_secret_value(),
}
payload = {
"url": input_data.url,
"numResults": input_data.number_of_results,
"contents": input_data.contents.dict(),
}
optional_field_mapping = {
"include_domains": "includeDomains",
"exclude_domains": "excludeDomains",
"include_text": "includeText",
"exclude_text": "excludeText",
}
# Add optional fields if they have values
for input_field, api_field in optional_field_mapping.items():
value = getattr(input_data, input_field)
if value: # Only add non-empty values
payload[api_field] = value
date_field_mapping = {
"start_crawl_date": "startCrawlDate",
"end_crawl_date": "endCrawlDate",
"start_published_date": "startPublishedDate",
"end_published_date": "endPublishedDate",
}
# Add dates if they exist
for input_field, api_field in date_field_mapping.items():
value = getattr(input_data, input_field, None)
if value:
payload[api_field] = value.strftime("%Y-%m-%dT%H:%M:%S.000Z")
try:
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
yield "results", data.get("results", [])
except Exception as e:
yield "error", str(e)
yield "results", []

View File

@ -1,3 +1,4 @@
import base64
import re import re
from typing_extensions import TypedDict from typing_extensions import TypedDict
@ -512,3 +513,420 @@ def prepare_pr_api_url(pr_url: str, path: str) -> str:
base_url, pr_number = match.groups() base_url, pr_number = match.groups()
return f"{base_url}/pulls/{pr_number}/{path}" return f"{base_url}/pulls/{pr_number}/{path}"
class GithubCreateFileBlock(Block):
class Input(BlockSchema):
credentials: GithubCredentialsInput = GithubCredentialsField("repo")
repo_url: str = SchemaField(
description="URL of the GitHub repository",
placeholder="https://github.com/owner/repo",
)
file_path: str = SchemaField(
description="Path where the file should be created",
placeholder="path/to/file.txt",
)
content: str = SchemaField(
description="Content to write to the file",
placeholder="File content here",
)
branch: str = SchemaField(
description="Branch where the file should be created",
default="main",
)
commit_message: str = SchemaField(
description="Message for the commit",
default="Create new file",
)
class Output(BlockSchema):
url: str = SchemaField(description="URL of the created file")
sha: str = SchemaField(description="SHA of the commit")
error: str = SchemaField(
description="Error message if the file creation failed"
)
def __init__(self):
super().__init__(
id="8fd132ac-b917-428a-8159-d62893e8a3fe",
description="This block creates a new file in a GitHub repository.",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=GithubCreateFileBlock.Input,
output_schema=GithubCreateFileBlock.Output,
test_input={
"repo_url": "https://github.com/owner/repo",
"file_path": "test/file.txt",
"content": "Test content",
"branch": "main",
"commit_message": "Create test file",
"credentials": TEST_CREDENTIALS_INPUT,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("url", "https://github.com/owner/repo/blob/main/test/file.txt"),
("sha", "abc123"),
],
test_mock={
"create_file": lambda *args, **kwargs: (
"https://github.com/owner/repo/blob/main/test/file.txt",
"abc123",
)
},
)
@staticmethod
def create_file(
credentials: GithubCredentials,
repo_url: str,
file_path: str,
content: str,
branch: str,
commit_message: str,
) -> tuple[str, str]:
api = get_api(credentials)
# Convert content to base64
content_bytes = content.encode("utf-8")
content_base64 = base64.b64encode(content_bytes).decode("utf-8")
# Create the file using the GitHub API
contents_url = f"{repo_url}/contents/{file_path}"
data = {
"message": commit_message,
"content": content_base64,
"branch": branch,
}
response = api.put(contents_url, json=data)
result = response.json()
return result["content"]["html_url"], result["commit"]["sha"]
def run(
self,
input_data: Input,
*,
credentials: GithubCredentials,
**kwargs,
) -> BlockOutput:
try:
url, sha = self.create_file(
credentials,
input_data.repo_url,
input_data.file_path,
input_data.content,
input_data.branch,
input_data.commit_message,
)
yield "url", url
yield "sha", sha
except Exception as e:
yield "error", str(e)
class GithubUpdateFileBlock(Block):
class Input(BlockSchema):
credentials: GithubCredentialsInput = GithubCredentialsField("repo")
repo_url: str = SchemaField(
description="URL of the GitHub repository",
placeholder="https://github.com/owner/repo",
)
file_path: str = SchemaField(
description="Path to the file to update",
placeholder="path/to/file.txt",
)
content: str = SchemaField(
description="New content for the file",
placeholder="Updated content here",
)
branch: str = SchemaField(
description="Branch containing the file",
default="main",
)
commit_message: str = SchemaField(
description="Message for the commit",
default="Update file",
)
class Output(BlockSchema):
url: str = SchemaField(description="URL of the updated file")
sha: str = SchemaField(description="SHA of the commit")
error: str = SchemaField(description="Error message if the file update failed")
def __init__(self):
super().__init__(
id="30be12a4-57cb-4aa4-baf5-fcc68d136076",
description="This block updates an existing file in a GitHub repository.",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=GithubUpdateFileBlock.Input,
output_schema=GithubUpdateFileBlock.Output,
test_input={
"repo_url": "https://github.com/owner/repo",
"file_path": "test/file.txt",
"content": "Updated content",
"branch": "main",
"commit_message": "Update test file",
"credentials": TEST_CREDENTIALS_INPUT,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("url", "https://github.com/owner/repo/blob/main/test/file.txt"),
("sha", "def456"),
],
test_mock={
"update_file": lambda *args, **kwargs: (
"https://github.com/owner/repo/blob/main/test/file.txt",
"def456",
)
},
)
@staticmethod
def update_file(
credentials: GithubCredentials,
repo_url: str,
file_path: str,
content: str,
branch: str,
commit_message: str,
) -> tuple[str, str]:
api = get_api(credentials)
# First get the current file to get its SHA
contents_url = f"{repo_url}/contents/{file_path}"
params = {"ref": branch}
response = api.get(contents_url, params=params)
current_file = response.json()
# Convert new content to base64
content_bytes = content.encode("utf-8")
content_base64 = base64.b64encode(content_bytes).decode("utf-8")
# Update the file
data = {
"message": commit_message,
"content": content_base64,
"sha": current_file["sha"],
"branch": branch,
}
response = api.put(contents_url, json=data)
result = response.json()
return result["content"]["html_url"], result["commit"]["sha"]
def run(
self,
input_data: Input,
*,
credentials: GithubCredentials,
**kwargs,
) -> BlockOutput:
try:
url, sha = self.update_file(
credentials,
input_data.repo_url,
input_data.file_path,
input_data.content,
input_data.branch,
input_data.commit_message,
)
yield "url", url
yield "sha", sha
except Exception as e:
yield "error", str(e)
class GithubCreateRepositoryBlock(Block):
class Input(BlockSchema):
credentials: GithubCredentialsInput = GithubCredentialsField("repo")
name: str = SchemaField(
description="Name of the repository to create",
placeholder="my-new-repo",
)
description: str = SchemaField(
description="Description of the repository",
placeholder="A description of the repository",
default="",
)
private: bool = SchemaField(
description="Whether the repository should be private",
default=False,
)
auto_init: bool = SchemaField(
description="Whether to initialize the repository with a README",
default=True,
)
gitignore_template: str = SchemaField(
description="Git ignore template to use (e.g., Python, Node, Java)",
default="",
)
class Output(BlockSchema):
url: str = SchemaField(description="URL of the created repository")
clone_url: str = SchemaField(description="Git clone URL of the repository")
error: str = SchemaField(
description="Error message if the repository creation failed"
)
def __init__(self):
super().__init__(
id="029ec3b8-1cfd-46d3-b6aa-28e4a706efd1",
description="This block creates a new GitHub repository.",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=GithubCreateRepositoryBlock.Input,
output_schema=GithubCreateRepositoryBlock.Output,
test_input={
"name": "test-repo",
"description": "A test repository",
"private": False,
"auto_init": True,
"gitignore_template": "Python",
"credentials": TEST_CREDENTIALS_INPUT,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("url", "https://github.com/owner/test-repo"),
("clone_url", "https://github.com/owner/test-repo.git"),
],
test_mock={
"create_repository": lambda *args, **kwargs: (
"https://github.com/owner/test-repo",
"https://github.com/owner/test-repo.git",
)
},
)
@staticmethod
def create_repository(
credentials: GithubCredentials,
name: str,
description: str,
private: bool,
auto_init: bool,
gitignore_template: str,
) -> tuple[str, str]:
api = get_api(credentials, convert_urls=False) # Disable URL conversion
data = {
"name": name,
"description": description,
"private": private,
"auto_init": auto_init,
}
if gitignore_template:
data["gitignore_template"] = gitignore_template
# Create repository using the user endpoint
response = api.post("https://api.github.com/user/repos", json=data)
result = response.json()
return result["html_url"], result["clone_url"]
def run(
self,
input_data: Input,
*,
credentials: GithubCredentials,
**kwargs,
) -> BlockOutput:
try:
url, clone_url = self.create_repository(
credentials,
input_data.name,
input_data.description,
input_data.private,
input_data.auto_init,
input_data.gitignore_template,
)
yield "url", url
yield "clone_url", clone_url
except Exception as e:
yield "error", str(e)
class GithubListStargazersBlock(Block):
class Input(BlockSchema):
credentials: GithubCredentialsInput = GithubCredentialsField("repo")
repo_url: str = SchemaField(
description="URL of the GitHub repository",
placeholder="https://github.com/owner/repo",
)
class Output(BlockSchema):
class StargazerItem(TypedDict):
username: str
url: str
stargazer: StargazerItem = SchemaField(
title="Stargazer",
description="Stargazers with their username and profile URL",
)
error: str = SchemaField(
description="Error message if listing stargazers failed"
)
def __init__(self):
super().__init__(
id="a4b9c2d1-e5f6-4g7h-8i9j-0k1l2m3n4o5p", # Generated unique UUID
description="This block lists all users who have starred a specified GitHub repository.",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=GithubListStargazersBlock.Input,
output_schema=GithubListStargazersBlock.Output,
test_input={
"repo_url": "https://github.com/owner/repo",
"credentials": TEST_CREDENTIALS_INPUT,
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"stargazer",
{
"username": "octocat",
"url": "https://github.com/octocat",
},
)
],
test_mock={
"list_stargazers": lambda *args, **kwargs: [
{
"username": "octocat",
"url": "https://github.com/octocat",
}
]
},
)
@staticmethod
def list_stargazers(
credentials: GithubCredentials, repo_url: str
) -> list[Output.StargazerItem]:
api = get_api(credentials)
# Add /stargazers to the repo URL to get stargazers endpoint
stargazers_url = f"{repo_url}/stargazers"
# Set accept header to get starred_at timestamp
headers = {"Accept": "application/vnd.github.star+json"}
response = api.get(stargazers_url, headers=headers)
data = response.json()
stargazers: list[GithubListStargazersBlock.Output.StargazerItem] = [
{
"username": stargazer["login"],
"url": stargazer["html_url"],
}
for stargazer in data
]
return stargazers
def run(
self,
input_data: Input,
*,
credentials: GithubCredentials,
**kwargs,
) -> BlockOutput:
try:
stargazers = self.list_stargazers(
credentials,
input_data.repo_url,
)
yield from (("stargazer", stargazer) for stargazer in stargazers)
except Exception as e:
yield "error", str(e)

View File

@ -42,6 +42,7 @@ class BlockType(Enum):
OUTPUT = "Output" OUTPUT = "Output"
NOTE = "Note" NOTE = "Note"
WEBHOOK = "Webhook" WEBHOOK = "Webhook"
WEBHOOK_MANUAL = "Webhook (manual)"
AGENT = "Agent" AGENT = "Agent"
@ -57,6 +58,7 @@ class BlockCategory(Enum):
COMMUNICATION = "Block that interacts with communication platforms." COMMUNICATION = "Block that interacts with communication platforms."
DEVELOPER_TOOLS = "Developer tools such as GitHub blocks." DEVELOPER_TOOLS = "Developer tools such as GitHub blocks."
DATA = "Block that interacts with structured data." DATA = "Block that interacts with structured data."
HARDWARE = "Block that interacts with hardware."
AGENT = "Block that interacts with other agents." AGENT = "Block that interacts with other agents."
CRM = "Block that interacts with CRM services." CRM = "Block that interacts with CRM services."
@ -95,11 +97,6 @@ class BlockSchema(BaseModel):
cls.cached_jsonschema = cast(dict[str, Any], ref_to_dict(model)) cls.cached_jsonschema = cast(dict[str, Any], ref_to_dict(model))
# Set default properties values
for field in cls.cached_jsonschema.get("properties", {}).values():
if isinstance(field, dict) and "advanced" not in field:
field["advanced"] = True
return cls.cached_jsonschema return cls.cached_jsonschema
@classmethod @classmethod
@ -197,7 +194,12 @@ class EmptySchema(BlockSchema):
# --8<-- [start:BlockWebhookConfig] # --8<-- [start:BlockWebhookConfig]
class BlockWebhookConfig(BaseModel): class BlockManualWebhookConfig(BaseModel):
"""
Configuration model for webhook-triggered blocks on which
the user has to manually set up the webhook at the provider.
"""
provider: str provider: str
"""The service provider that the webhook connects to""" """The service provider that the webhook connects to"""
@ -208,6 +210,27 @@ class BlockWebhookConfig(BaseModel):
Only for use in the corresponding `WebhooksManager`. Only for use in the corresponding `WebhooksManager`.
""" """
event_filter_input: str = ""
"""
Name of the block's event filter input.
Leave empty if the corresponding webhook doesn't have distinct event/payload types.
"""
event_format: str = "{event}"
"""
Template string for the event(s) that a block instance subscribes to.
Applied individually to each event selected in the event filter input.
Example: `"pull_request.{event}"` -> `"pull_request.opened"`
"""
class BlockWebhookConfig(BlockManualWebhookConfig):
"""
Configuration model for webhook-triggered blocks for which
the webhook can be automatically set up through the provider's API.
"""
resource_format: str resource_format: str
""" """
Template string for the resource that a block instance subscribes to. Template string for the resource that a block instance subscribes to.
@ -217,17 +240,6 @@ class BlockWebhookConfig(BaseModel):
Only for use in the corresponding `WebhooksManager`. Only for use in the corresponding `WebhooksManager`.
""" """
event_filter_input: str
"""Name of the block's event filter input."""
event_format: str = "{event}"
"""
Template string for the event(s) that a block instance subscribes to.
Applied individually to each event selected in the event filter input.
Example: `"pull_request.{event}"` -> `"pull_request.opened"`
"""
# --8<-- [end:BlockWebhookConfig] # --8<-- [end:BlockWebhookConfig]
@ -247,7 +259,7 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
disabled: bool = False, disabled: bool = False,
static_output: bool = False, static_output: bool = False,
block_type: BlockType = BlockType.STANDARD, block_type: BlockType = BlockType.STANDARD,
webhook_config: Optional[BlockWebhookConfig] = None, webhook_config: Optional[BlockWebhookConfig | BlockManualWebhookConfig] = None,
): ):
""" """
Initialize the block with the given schema. Initialize the block with the given schema.
@ -278,12 +290,23 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
self.contributors = contributors or set() self.contributors = contributors or set()
self.disabled = disabled self.disabled = disabled
self.static_output = static_output self.static_output = static_output
self.block_type = block_type if not webhook_config else BlockType.WEBHOOK self.block_type = block_type
self.webhook_config = webhook_config self.webhook_config = webhook_config
self.execution_stats = {} self.execution_stats = {}
if self.webhook_config: if self.webhook_config:
# Enforce shape of webhook event filter if isinstance(self.webhook_config, BlockWebhookConfig):
# Enforce presence of credentials field on auto-setup webhook blocks
if CREDENTIALS_FIELD_NAME not in self.input_schema.model_fields:
raise TypeError(
"credentials field is required on auto-setup webhook blocks"
)
self.block_type = BlockType.WEBHOOK
else:
self.block_type = BlockType.WEBHOOK_MANUAL
# Enforce shape of webhook event filter, if present
if self.webhook_config.event_filter_input:
event_filter_field = self.input_schema.model_fields[ event_filter_field = self.input_schema.model_fields[
self.webhook_config.event_filter_input self.webhook_config.event_filter_input
] ]

View File

@ -84,6 +84,8 @@ class NodeModel(Node):
raise ValueError(f"Block #{self.block_id} not found for node #{self.id}") raise ValueError(f"Block #{self.block_id} not found for node #{self.id}")
if not block.webhook_config: if not block.webhook_config:
raise TypeError("This method can't be used on non-webhook blocks") raise TypeError("This method can't be used on non-webhook blocks")
if not block.webhook_config.event_filter_input:
return True
event_filter = self.input_default.get(block.webhook_config.event_filter_input) event_filter = self.input_default.get(block.webhook_config.event_filter_input)
if not event_filter: if not event_filter:
raise ValueError(f"Event filter is not configured on node #{self.id}") raise ValueError(f"Event filter is not configured on node #{self.id}")
@ -191,7 +193,8 @@ class Graph(BaseDbModel):
"properties": { "properties": {
p.name: { p.name: {
"secret": p.secret, "secret": p.secret,
"advanced": p.advanced, # Default value has to be set for advanced fields.
"advanced": p.advanced and p.value is not None,
"title": p.title or p.name, "title": p.title or p.name,
**({"description": p.description} if p.description else {}), **({"description": p.description} if p.description else {}),
**({"default": p.value} if p.value is not None else {}), **({"default": p.value} if p.value is not None else {}),
@ -268,11 +271,19 @@ class GraphModel(Graph):
+ [sanitize(link.sink_name) for link in input_links.get(node.id, [])] + [sanitize(link.sink_name) for link in input_links.get(node.id, [])]
) )
for name in block.input_schema.get_required_fields(): for name in block.input_schema.get_required_fields():
if name not in provided_inputs and ( if (
name not in provided_inputs
and not (
name == "payload"
and block.block_type
in (BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL)
)
and (
for_run # Skip input completion validation, unless when executing. for_run # Skip input completion validation, unless when executing.
or block.block_type == BlockType.INPUT or block.block_type == BlockType.INPUT
or block.block_type == BlockType.OUTPUT or block.block_type == BlockType.OUTPUT
or block.block_type == BlockType.AGENT or block.block_type == BlockType.AGENT
)
): ):
raise ValueError( raise ValueError(
f"Node {block.name} #{node.id} required input missing: `{name}`" f"Node {block.name} #{node.id} required input missing: `{name}`"
@ -292,7 +303,6 @@ class GraphModel(Graph):
# Validate dependencies between fields # Validate dependencies between fields
for field_name, field_info in input_schema.items(): for field_name, field_info in input_schema.items():
# Apply input dependency validation only on run & field with depends_on # Apply input dependency validation only on run & field with depends_on
json_schema_extra = field_info.json_schema_extra or {} json_schema_extra = field_info.json_schema_extra or {}
dependencies = json_schema_extra.get("depends_on", []) dependencies = json_schema_extra.get("depends_on", [])
@ -359,7 +369,7 @@ class GraphModel(Graph):
link.is_static = True # Each value block output should be static. link.is_static = True # Each value block output should be static.
@staticmethod @staticmethod
def from_db(graph: AgentGraph, hide_credentials: bool = False): def from_db(graph: AgentGraph, for_export: bool = False):
return GraphModel( return GraphModel(
id=graph.id, id=graph.id,
user_id=graph.userId, user_id=graph.userId,
@ -369,7 +379,7 @@ class GraphModel(Graph):
name=graph.name or "", name=graph.name or "",
description=graph.description or "", description=graph.description or "",
nodes=[ nodes=[
GraphModel._process_node(node, hide_credentials) NodeModel.from_db(GraphModel._process_node(node, for_export))
for node in graph.AgentNodes or [] for node in graph.AgentNodes or []
], ],
links=list( links=list(
@ -382,23 +392,29 @@ class GraphModel(Graph):
) )
@staticmethod @staticmethod
def _process_node(node: AgentNode, hide_credentials: bool) -> NodeModel: def _process_node(node: AgentNode, for_export: bool) -> AgentNode:
node_dict = {field: getattr(node, field) for field in node.model_fields} if for_export:
if hide_credentials and "constantInput" in node_dict: # Remove credentials from node input
if node.constantInput:
constant_input = json.loads( constant_input = json.loads(
node_dict["constantInput"], target_type=dict[str, Any] node.constantInput, target_type=dict[str, Any]
) )
constant_input = GraphModel._hide_credentials_in_input(constant_input) constant_input = GraphModel._hide_node_input_credentials(constant_input)
node_dict["constantInput"] = json.dumps(constant_input) node.constantInput = json.dumps(constant_input)
return NodeModel.from_db(AgentNode(**node_dict))
# Remove webhook info
node.webhookId = None
node.Webhook = None
return node
@staticmethod @staticmethod
def _hide_credentials_in_input(input_data: dict[str, Any]) -> dict[str, Any]: def _hide_node_input_credentials(input_data: dict[str, Any]) -> dict[str, Any]:
sensitive_keys = ["credentials", "api_key", "password", "token", "secret"] sensitive_keys = ["credentials", "api_key", "password", "token", "secret"]
result = {} result = {}
for key, value in input_data.items(): for key, value in input_data.items():
if isinstance(value, dict): if isinstance(value, dict):
result[key] = GraphModel._hide_credentials_in_input(value) result[key] = GraphModel._hide_node_input_credentials(value)
elif isinstance(value, str) and any( elif isinstance(value, str) and any(
sensitive_key in key.lower() for sensitive_key in sensitive_keys sensitive_key in key.lower() for sensitive_key in sensitive_keys
): ):
@ -495,7 +511,7 @@ async def get_graph(
version: int | None = None, version: int | None = None,
template: bool = False, template: bool = False,
user_id: str | None = None, user_id: str | None = None,
hide_credentials: bool = False, for_export: bool = False,
) -> GraphModel | None: ) -> GraphModel | None:
""" """
Retrieves a graph from the DB. Retrieves a graph from the DB.
@ -506,13 +522,13 @@ async def get_graph(
""" """
where_clause: AgentGraphWhereInput = { where_clause: AgentGraphWhereInput = {
"id": graph_id, "id": graph_id,
"isTemplate": template,
} }
if version is not None: if version is not None:
where_clause["version"] = version where_clause["version"] = version
elif not template: elif not template:
where_clause["isActive"] = True where_clause["isActive"] = True
# TODO: Fix hack workaround to get adding store agents to work
if user_id is not None and not template: if user_id is not None and not template:
where_clause["userId"] = user_id where_clause["userId"] = user_id
@ -521,7 +537,7 @@ async def get_graph(
include=AGENT_GRAPH_INCLUDE, include=AGENT_GRAPH_INCLUDE,
order={"version": "desc"}, order={"version": "desc"},
) )
return GraphModel.from_db(graph, hide_credentials) if graph else None return GraphModel.from_db(graph, for_export) if graph else None
async def set_graph_active_version(graph_id: str, version: int, user_id: str) -> None: async def set_graph_active_version(graph_id: str, version: int, user_id: str) -> None:

View File

@ -3,11 +3,12 @@ from typing import TYPE_CHECKING, AsyncGenerator, Optional
from prisma import Json from prisma import Json
from prisma.models import IntegrationWebhook from prisma.models import IntegrationWebhook
from pydantic import Field from pydantic import Field, computed_field
from backend.data.includes import INTEGRATION_WEBHOOK_INCLUDE from backend.data.includes import INTEGRATION_WEBHOOK_INCLUDE
from backend.data.queue import AsyncRedisEventBus from backend.data.queue import AsyncRedisEventBus
from backend.integrations.providers import ProviderName from backend.integrations.providers import ProviderName
from backend.integrations.webhooks.utils import webhook_ingress_url
from .db import BaseDbModel from .db import BaseDbModel
@ -31,6 +32,11 @@ class Webhook(BaseDbModel):
attached_nodes: Optional[list["NodeModel"]] = None attached_nodes: Optional[list["NodeModel"]] = None
@computed_field
@property
def url(self) -> str:
return webhook_ingress_url(self.provider.value, self.id)
@staticmethod @staticmethod
def from_db(webhook: IntegrationWebhook): def from_db(webhook: IntegrationWebhook):
from .graph import NodeModel from .graph import NodeModel
@ -84,8 +90,10 @@ async def get_webhook(webhook_id: str) -> Webhook:
return Webhook.from_db(webhook) return Webhook.from_db(webhook)
async def get_all_webhooks(credentials_id: str) -> list[Webhook]: async def get_all_webhooks_by_creds(credentials_id: str) -> list[Webhook]:
"""⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints."""
if not credentials_id:
raise ValueError("credentials_id must not be empty")
webhooks = await IntegrationWebhook.prisma().find_many( webhooks = await IntegrationWebhook.prisma().find_many(
where={"credentialsId": credentials_id}, where={"credentialsId": credentials_id},
include=INTEGRATION_WEBHOOK_INCLUDE, include=INTEGRATION_WEBHOOK_INCLUDE,
@ -93,7 +101,7 @@ async def get_all_webhooks(credentials_id: str) -> list[Webhook]:
return [Webhook.from_db(webhook) for webhook in webhooks] return [Webhook.from_db(webhook) for webhook in webhooks]
async def find_webhook( async def find_webhook_by_credentials_and_props(
credentials_id: str, webhook_type: str, resource: str, events: list[str] credentials_id: str, webhook_type: str, resource: str, events: list[str]
) -> Webhook | None: ) -> Webhook | None:
"""⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints."""
@ -109,6 +117,22 @@ async def find_webhook(
return Webhook.from_db(webhook) if webhook else None return Webhook.from_db(webhook) if webhook else None
async def find_webhook_by_graph_and_props(
graph_id: str, provider: str, webhook_type: str, events: list[str]
) -> Webhook | None:
"""⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints."""
webhook = await IntegrationWebhook.prisma().find_first(
where={
"provider": provider,
"webhookType": webhook_type,
"events": {"has_every": events},
"AgentNodes": {"some": {"agentGraphId": graph_id}},
},
include=INTEGRATION_WEBHOOK_INCLUDE,
)
return Webhook.from_db(webhook) if webhook else None
async def update_webhook_config(webhook_id: str, updated_config: dict) -> Webhook: async def update_webhook_config(webhook_id: str, updated_config: dict) -> Webhook:
"""⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints."""
_updated_webhook = await IntegrationWebhook.prisma().update( _updated_webhook = await IntegrationWebhook.prisma().update(

View File

@ -134,13 +134,18 @@ def SchemaField(
title: Optional[str] = None, title: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
placeholder: Optional[str] = None, placeholder: Optional[str] = None,
advanced: Optional[bool] = False, advanced: Optional[bool] = None,
secret: bool = False, secret: bool = False,
exclude: bool = False, exclude: bool = False,
hidden: Optional[bool] = None, hidden: Optional[bool] = None,
depends_on: list[str] | None = None, depends_on: list[str] | None = None,
**kwargs, **kwargs,
) -> T: ) -> T:
if default is PydanticUndefined and default_factory is None:
advanced = False
elif advanced is None:
advanced = True
json_extra = { json_extra = {
k: v k: v
for k, v in { for k, v in {

View File

@ -798,10 +798,13 @@ class ExecutionManager(AppService):
# Extract webhook payload, and assign it to the input pin # Extract webhook payload, and assign it to the input pin
webhook_payload_key = f"webhook_{node.webhook_id}_payload" webhook_payload_key = f"webhook_{node.webhook_id}_payload"
if ( if (
block.block_type == BlockType.WEBHOOK block.block_type in (BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL)
and node.webhook_id and node.webhook_id
and webhook_payload_key in data
): ):
if webhook_payload_key not in data:
raise ValueError(
f"Node {block.name} #{node.id} webhook payload is missing"
)
input_data = {"payload": data[webhook_payload_key]} input_data = {"payload": data[webhook_payload_key]}
input_data, error = validate_exec(node, input_data) input_data, error = validate_exec(node, input_data)
@ -944,6 +947,7 @@ def synchronized(key: str, timeout: int = 60):
lock.acquire() lock.acquire()
yield yield
finally: finally:
if lock.locked():
lock.release() lock.release()

View File

@ -99,6 +99,10 @@ class ExecutionScheduler(AppService):
def get_port(cls) -> int: def get_port(cls) -> int:
return config.execution_scheduler_port return config.execution_scheduler_port
@classmethod
def db_pool_size(cls) -> int:
return config.scheduler_db_pool_size
@property @property
@thread_cached @thread_cached
def execution_client(self) -> ExecutionManager: def execution_client(self) -> ExecutionManager:
@ -110,7 +114,11 @@ class ExecutionScheduler(AppService):
self.scheduler = BlockingScheduler( self.scheduler = BlockingScheduler(
jobstores={ jobstores={
"default": SQLAlchemyJobStore( "default": SQLAlchemyJobStore(
engine=create_engine(db_url), engine=create_engine(
url=db_url,
pool_size=self.db_pool_size(),
max_overflow=0,
),
metadata=MetaData(schema=db_schema), metadata=MetaData(schema=db_schema),
) )
} }

View File

@ -92,7 +92,7 @@ class IntegrationCredentialsManager:
fresh_credentials = oauth_handler.refresh_tokens(credentials) fresh_credentials = oauth_handler.refresh_tokens(credentials)
self.store.update_creds(user_id, fresh_credentials) self.store.update_creds(user_id, fresh_credentials)
if _lock: if _lock and _lock.locked():
_lock.release() _lock.release()
credentials = fresh_credentials credentials = fresh_credentials
@ -144,6 +144,7 @@ class IntegrationCredentialsManager:
try: try:
yield yield
finally: finally:
if lock.locked():
lock.release() lock.release()
def release_all_locks(self): def release_all_locks(self):

View File

@ -4,6 +4,7 @@ from enum import Enum
# --8<-- [start:ProviderName] # --8<-- [start:ProviderName]
class ProviderName(str, Enum): class ProviderName(str, Enum):
ANTHROPIC = "anthropic" ANTHROPIC = "anthropic"
COMPASS = "compass"
DISCORD = "discord" DISCORD = "discord"
D_ID = "d_id" D_ID = "d_id"
E2B = "e2b" E2B = "e2b"

View File

@ -1,16 +1,18 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .compass import CompassWebhookManager
from .github import GithubWebhooksManager from .github import GithubWebhooksManager
from .slant3d import Slant3DWebhooksManager from .slant3d import Slant3DWebhooksManager
if TYPE_CHECKING: if TYPE_CHECKING:
from ..providers import ProviderName from ..providers import ProviderName
from .base import BaseWebhooksManager from ._base import BaseWebhooksManager
# --8<-- [start:WEBHOOK_MANAGERS_BY_NAME] # --8<-- [start:WEBHOOK_MANAGERS_BY_NAME]
WEBHOOK_MANAGERS_BY_NAME: dict["ProviderName", type["BaseWebhooksManager"]] = { WEBHOOK_MANAGERS_BY_NAME: dict["ProviderName", type["BaseWebhooksManager"]] = {
handler.PROVIDER_NAME: handler handler.PROVIDER_NAME: handler
for handler in [ for handler in [
CompassWebhookManager,
GithubWebhooksManager, GithubWebhooksManager,
Slant3DWebhooksManager, Slant3DWebhooksManager,
] ]

View File

@ -1,7 +1,7 @@
import logging import logging
import secrets import secrets
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import ClassVar, Generic, TypeVar from typing import ClassVar, Generic, Optional, TypeVar
from uuid import uuid4 from uuid import uuid4
from fastapi import Request from fastapi import Request
@ -10,6 +10,7 @@ from strenum import StrEnum
from backend.data import integrations from backend.data import integrations
from backend.data.model import Credentials from backend.data.model import Credentials
from backend.integrations.providers import ProviderName from backend.integrations.providers import ProviderName
from backend.integrations.webhooks.utils import webhook_ingress_url
from backend.util.exceptions import MissingConfigError from backend.util.exceptions import MissingConfigError
from backend.util.settings import Config from backend.util.settings import Config
@ -26,7 +27,7 @@ class BaseWebhooksManager(ABC, Generic[WT]):
WebhookType: WT WebhookType: WT
async def get_suitable_webhook( async def get_suitable_auto_webhook(
self, self,
user_id: str, user_id: str,
credentials: Credentials, credentials: Credentials,
@ -39,16 +40,34 @@ class BaseWebhooksManager(ABC, Generic[WT]):
"PLATFORM_BASE_URL must be set to use Webhook functionality" "PLATFORM_BASE_URL must be set to use Webhook functionality"
) )
if webhook := await integrations.find_webhook( if webhook := await integrations.find_webhook_by_credentials_and_props(
credentials.id, webhook_type, resource, events credentials.id, webhook_type, resource, events
): ):
return webhook return webhook
return await self._create_webhook( return await self._create_webhook(
user_id, credentials, webhook_type, resource, events user_id, webhook_type, events, resource, credentials
)
async def get_manual_webhook(
self,
user_id: str,
graph_id: str,
webhook_type: WT,
events: list[str],
):
if current_webhook := await integrations.find_webhook_by_graph_and_props(
graph_id, self.PROVIDER_NAME, webhook_type, events
):
return current_webhook
return await self._create_webhook(
user_id,
webhook_type,
events,
register=False,
) )
async def prune_webhook_if_dangling( async def prune_webhook_if_dangling(
self, webhook_id: str, credentials: Credentials self, webhook_id: str, credentials: Optional[Credentials]
) -> bool: ) -> bool:
webhook = await integrations.get_webhook(webhook_id) webhook = await integrations.get_webhook(webhook_id)
if webhook.attached_nodes is None: if webhook.attached_nodes is None:
@ -57,6 +76,7 @@ class BaseWebhooksManager(ABC, Generic[WT]):
# Don't prune webhook if in use # Don't prune webhook if in use
return False return False
if credentials:
await self._deregister_webhook(webhook, credentials) await self._deregister_webhook(webhook, credentials)
await integrations.delete_webhook(webhook.id) await integrations.delete_webhook(webhook.id)
return True return True
@ -135,27 +155,36 @@ class BaseWebhooksManager(ABC, Generic[WT]):
async def _create_webhook( async def _create_webhook(
self, self,
user_id: str, user_id: str,
credentials: Credentials,
webhook_type: WT, webhook_type: WT,
resource: str,
events: list[str], events: list[str],
resource: str = "",
credentials: Optional[Credentials] = None,
register: bool = True,
) -> integrations.Webhook: ) -> integrations.Webhook:
if not app_config.platform_base_url:
raise MissingConfigError(
"PLATFORM_BASE_URL must be set to use Webhook functionality"
)
id = str(uuid4()) id = str(uuid4())
secret = secrets.token_hex(32) secret = secrets.token_hex(32)
provider_name = self.PROVIDER_NAME provider_name = self.PROVIDER_NAME
ingress_url = ( ingress_url = webhook_ingress_url(provider_name=provider_name, webhook_id=id)
f"{app_config.platform_base_url}/api/integrations/{provider_name.value}" if register:
f"/webhooks/{id}/ingress" if not credentials:
) raise TypeError("credentials are required if register = True")
provider_webhook_id, config = await self._register_webhook( provider_webhook_id, config = await self._register_webhook(
credentials, webhook_type, resource, events, ingress_url, secret credentials, webhook_type, resource, events, ingress_url, secret
) )
else:
provider_webhook_id, config = "", {}
return await integrations.create_webhook( return await integrations.create_webhook(
integrations.Webhook( integrations.Webhook(
id=id, id=id,
user_id=user_id, user_id=user_id,
provider=provider_name, provider=provider_name,
credentials_id=credentials.id, credentials_id=credentials.id if credentials else "",
webhook_type=webhook_type, webhook_type=webhook_type,
resource=resource, resource=resource,
events=events, events=events,

View File

@ -0,0 +1,30 @@
import logging
from backend.data import integrations
from backend.data.model import APIKeyCredentials, Credentials, OAuth2Credentials
from ._base import WT, BaseWebhooksManager
logger = logging.getLogger(__name__)
class ManualWebhookManagerBase(BaseWebhooksManager[WT]):
async def _register_webhook(
self,
credentials: Credentials,
webhook_type: WT,
resource: str,
events: list[str],
ingress_url: str,
secret: str,
) -> tuple[str, dict]:
print(ingress_url) # FIXME: pass URL to user in front end
return "", {}
async def _deregister_webhook(
self,
webhook: integrations.Webhook,
credentials: OAuth2Credentials | APIKeyCredentials,
) -> None:
pass

View File

@ -0,0 +1,30 @@
import logging
from fastapi import Request
from strenum import StrEnum
from backend.data import integrations
from backend.integrations.providers import ProviderName
from ._manual_base import ManualWebhookManagerBase
logger = logging.getLogger(__name__)
class CompassWebhookType(StrEnum):
TRANSCRIPTION = "transcription"
TASK = "task"
class CompassWebhookManager(ManualWebhookManagerBase):
PROVIDER_NAME = ProviderName.COMPASS
WebhookType = CompassWebhookType
@classmethod
async def validate_payload(
cls, webhook: integrations.Webhook, request: Request
) -> tuple[dict, str]:
payload = await request.json()
event_type = CompassWebhookType.TRANSCRIPTION # currently the only type
return payload, event_type

View File

@ -10,7 +10,7 @@ from backend.data import integrations
from backend.data.model import Credentials from backend.data.model import Credentials
from backend.integrations.providers import ProviderName from backend.integrations.providers import ProviderName
from .base import BaseWebhooksManager from ._base import BaseWebhooksManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -1,7 +1,7 @@
import logging import logging
from typing import TYPE_CHECKING, Callable, Optional, cast from typing import TYPE_CHECKING, Callable, Optional, cast
from backend.data.block import get_block from backend.data.block import BlockWebhookConfig, get_block
from backend.data.graph import set_node_webhook from backend.data.graph import set_node_webhook
from backend.data.model import CREDENTIALS_FIELD_NAME from backend.data.model import CREDENTIALS_FIELD_NAME
from backend.integrations.webhooks import WEBHOOK_MANAGERS_BY_NAME from backend.integrations.webhooks import WEBHOOK_MANAGERS_BY_NAME
@ -10,7 +10,7 @@ if TYPE_CHECKING:
from backend.data.graph import GraphModel, NodeModel from backend.data.graph import GraphModel, NodeModel
from backend.data.model import Credentials from backend.data.model import Credentials
from .base import BaseWebhooksManager from ._base import BaseWebhooksManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -108,6 +108,7 @@ async def on_node_activate(
webhooks_manager = WEBHOOK_MANAGERS_BY_NAME[provider]() webhooks_manager = WEBHOOK_MANAGERS_BY_NAME[provider]()
if auto_setup_webhook := isinstance(block.webhook_config, BlockWebhookConfig):
try: try:
resource = block.webhook_config.resource_format.format(**node.input_default) resource = block.webhook_config.resource_format.format(**node.input_default)
except KeyError: except KeyError:
@ -115,24 +116,38 @@ async def on_node_activate(
logger.debug( logger.debug(
f"Constructed resource string {resource} from input {node.input_default}" f"Constructed resource string {resource} from input {node.input_default}"
) )
else:
resource = "" # not relevant for manual webhooks
needs_credentials = CREDENTIALS_FIELD_NAME in block.input_schema.model_fields
credentials_meta = (
node.input_default.get(CREDENTIALS_FIELD_NAME) if needs_credentials else None
)
event_filter_input_name = block.webhook_config.event_filter_input event_filter_input_name = block.webhook_config.event_filter_input
has_everything_for_webhook = ( has_everything_for_webhook = (
resource is not None resource is not None
and CREDENTIALS_FIELD_NAME in node.input_default and (credentials_meta or not needs_credentials)
and event_filter_input_name in node.input_default and (
and any(is_on for is_on in node.input_default[event_filter_input_name].values()) not event_filter_input_name
or (
event_filter_input_name in node.input_default
and any(
is_on
for is_on in node.input_default[event_filter_input_name].values()
)
)
)
) )
if has_everything_for_webhook and resource: if has_everything_for_webhook and resource is not None:
logger.debug(f"Node #{node} has everything for a webhook!") logger.debug(f"Node #{node} has everything for a webhook!")
if not credentials: if credentials_meta and not credentials:
credentials_meta = node.input_default[CREDENTIALS_FIELD_NAME]
raise ValueError( raise ValueError(
f"Cannot set up webhook for node #{node.id}: " f"Cannot set up webhook for node #{node.id}: "
f"credentials #{credentials_meta['id']} not available" f"credentials #{credentials_meta['id']} not available"
) )
if event_filter_input_name:
# Shape of the event filter is enforced in Block.__init__ # Shape of the event filter is enforced in Block.__init__
event_filter = cast(dict, node.input_default[event_filter_input_name]) event_filter = cast(dict, node.input_default[event_filter_input_name])
events = [ events = [
@ -141,17 +156,31 @@ async def on_node_activate(
if enabled is True if enabled is True
] ]
logger.debug(f"Webhook events to subscribe to: {', '.join(events)}") logger.debug(f"Webhook events to subscribe to: {', '.join(events)}")
else:
events = []
# Find/make and attach a suitable webhook to the node # Find/make and attach a suitable webhook to the node
new_webhook = await webhooks_manager.get_suitable_webhook( if auto_setup_webhook:
assert credentials is not None
new_webhook = await webhooks_manager.get_suitable_auto_webhook(
user_id, user_id,
credentials, credentials,
block.webhook_config.webhook_type, block.webhook_config.webhook_type,
resource, resource,
events, events,
) )
else:
# Manual webhook -> no credentials -> don't register but do create
new_webhook = await webhooks_manager.get_manual_webhook(
user_id,
node.graph_id,
block.webhook_config.webhook_type,
events,
)
logger.debug(f"Acquired webhook: {new_webhook}") logger.debug(f"Acquired webhook: {new_webhook}")
return await set_node_webhook(node.id, new_webhook.id) return await set_node_webhook(node.id, new_webhook.id)
else:
logger.debug(f"Node #{node.id} does not have everything for a webhook")
return node return node
@ -194,12 +223,16 @@ async def on_node_deactivate(
updated_node = await set_node_webhook(node.id, None) updated_node = await set_node_webhook(node.id, None)
# Prune and deregister the webhook if it is no longer used anywhere # Prune and deregister the webhook if it is no longer used anywhere
logger.debug("Pruning and deregistering webhook if dangling")
webhook = node.webhook webhook = node.webhook
if credentials: logger.debug(
logger.debug(f"Pruning webhook #{webhook.id} with credentials") f"Pruning{' and deregistering' if credentials else ''} "
f"webhook #{webhook.id}"
)
await webhooks_manager.prune_webhook_if_dangling(webhook.id, credentials) await webhooks_manager.prune_webhook_if_dangling(webhook.id, credentials)
else: if (
CREDENTIALS_FIELD_NAME in block.input_schema.model_fields
and not credentials
):
logger.warning( logger.warning(
f"Cannot deregister webhook #{webhook.id}: credentials " f"Cannot deregister webhook #{webhook.id}: credentials "
f"#{webhook.credentials_id} not available " f"#{webhook.credentials_id} not available "

View File

@ -6,7 +6,7 @@ from fastapi import Request
from backend.data import integrations from backend.data import integrations
from backend.data.model import APIKeyCredentials, Credentials from backend.data.model import APIKeyCredentials, Credentials
from backend.integrations.providers import ProviderName from backend.integrations.providers import ProviderName
from backend.integrations.webhooks.base import BaseWebhooksManager from backend.integrations.webhooks._base import BaseWebhooksManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -0,0 +1,11 @@
from backend.util.settings import Config
app_config = Config()
# TODO: add test to assert this matches the actual API route
def webhook_ingress_url(provider_name: str, webhook_id: str) -> str:
return (
f"{app_config.platform_base_url}/api/integrations/{provider_name}"
f"/webhooks/{webhook_id}/ingress"
)

View File

@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, SecretStr
from backend.data.graph import set_node_webhook from backend.data.graph import set_node_webhook
from backend.data.integrations import ( from backend.data.integrations import (
WebhookEvent, WebhookEvent,
get_all_webhooks, get_all_webhooks_by_creds,
get_webhook, get_webhook,
publish_webhook_event, publish_webhook_event,
wait_for_webhook_event, wait_for_webhook_event,
@ -363,7 +363,7 @@ async def remove_all_webhooks_for_credentials(
Raises: Raises:
NeedConfirmation: If any of the webhooks are still in use and `force` is `False` NeedConfirmation: If any of the webhooks are still in use and `force` is `False`
""" """
webhooks = await get_all_webhooks(credentials.id) webhooks = await get_all_webhooks_by_creds(credentials.id)
if credentials.provider not in WEBHOOK_MANAGERS_BY_NAME: if credentials.provider not in WEBHOOK_MANAGERS_BY_NAME:
if webhooks: if webhooks:
logger.error( logger.error(

View File

@ -196,7 +196,7 @@ class DeleteGraphResponse(TypedDict):
@v1_router.get(path="/graphs", tags=["graphs"], dependencies=[Depends(auth_middleware)]) @v1_router.get(path="/graphs", tags=["graphs"], dependencies=[Depends(auth_middleware)])
async def get_graphs( async def get_graphs(
user_id: Annotated[str, Depends(get_user_id)] user_id: Annotated[str, Depends(get_user_id)]
) -> Sequence[graph_db.Graph]: ) -> Sequence[graph_db.GraphModel]:
return await graph_db.get_graphs(filter_by="active", user_id=user_id) return await graph_db.get_graphs(filter_by="active", user_id=user_id)
@ -213,9 +213,9 @@ async def get_graph(
user_id: Annotated[str, Depends(get_user_id)], user_id: Annotated[str, Depends(get_user_id)],
version: int | None = None, version: int | None = None,
hide_credentials: bool = False, hide_credentials: bool = False,
) -> graph_db.Graph: ) -> graph_db.GraphModel:
graph = await graph_db.get_graph( graph = await graph_db.get_graph(
graph_id, version, user_id=user_id, hide_credentials=hide_credentials graph_id, version, user_id=user_id, for_export=hide_credentials
) )
if not graph: if not graph:
raise HTTPException(status_code=404, detail=f"Graph #{graph_id} not found.") raise HTTPException(status_code=404, detail=f"Graph #{graph_id} not found.")
@ -234,7 +234,7 @@ async def get_graph(
) )
async def get_graph_all_versions( async def get_graph_all_versions(
graph_id: str, user_id: Annotated[str, Depends(get_user_id)] graph_id: str, user_id: Annotated[str, Depends(get_user_id)]
) -> Sequence[graph_db.Graph]: ) -> Sequence[graph_db.GraphModel]:
graphs = await graph_db.get_graph_all_versions(graph_id, user_id=user_id) graphs = await graph_db.get_graph_all_versions(graph_id, user_id=user_id)
if not graphs: if not graphs:
raise HTTPException(status_code=404, detail=f"Graph #{graph_id} not found.") raise HTTPException(status_code=404, detail=f"Graph #{graph_id} not found.")
@ -246,7 +246,7 @@ async def get_graph_all_versions(
) )
async def create_new_graph( async def create_new_graph(
create_graph: CreateGraph, user_id: Annotated[str, Depends(get_user_id)] create_graph: CreateGraph, user_id: Annotated[str, Depends(get_user_id)]
) -> graph_db.Graph: ) -> graph_db.GraphModel:
return await do_create_graph(create_graph, is_template=False, user_id=user_id) return await do_create_graph(create_graph, is_template=False, user_id=user_id)
@ -256,7 +256,7 @@ async def do_create_graph(
# user_id doesn't have to be annotated like on other endpoints, # user_id doesn't have to be annotated like on other endpoints,
# because create_graph isn't used directly as an endpoint # because create_graph isn't used directly as an endpoint
user_id: str, user_id: str,
) -> graph_db.Graph: ) -> graph_db.GraphModel:
if create_graph.graph: if create_graph.graph:
graph = graph_db.make_graph_model(create_graph.graph, user_id) graph = graph_db.make_graph_model(create_graph.graph, user_id)
elif create_graph.template_id: elif create_graph.template_id:
@ -317,7 +317,7 @@ async def update_graph(
graph_id: str, graph_id: str,
graph: graph_db.Graph, graph: graph_db.Graph,
user_id: Annotated[str, Depends(get_user_id)], user_id: Annotated[str, Depends(get_user_id)],
) -> graph_db.Graph: ) -> graph_db.GraphModel:
# Sanity check # Sanity check
if graph.id and graph.id != graph_id: if graph.id and graph.id != graph_id:
raise HTTPException(400, detail="Graph ID does not match ID in URI") raise HTTPException(400, detail="Graph ID does not match ID in URI")
@ -487,7 +487,7 @@ async def get_graph_run_node_execution_results(
) )
async def get_templates( async def get_templates(
user_id: Annotated[str, Depends(get_user_id)] user_id: Annotated[str, Depends(get_user_id)]
) -> Sequence[graph_db.Graph]: ) -> Sequence[graph_db.GraphModel]:
return await graph_db.get_graphs(filter_by="template", user_id=user_id) return await graph_db.get_graphs(filter_by="template", user_id=user_id)
@ -496,7 +496,9 @@ async def get_templates(
tags=["templates", "graphs"], tags=["templates", "graphs"],
dependencies=[Depends(auth_middleware)], dependencies=[Depends(auth_middleware)],
) )
async def get_template(graph_id: str, version: int | None = None) -> graph_db.Graph: async def get_template(
graph_id: str, version: int | None = None
) -> graph_db.GraphModel:
graph = await graph_db.get_graph(graph_id, version, template=True) graph = await graph_db.get_graph(graph_id, version, template=True)
if not graph: if not graph:
raise HTTPException(status_code=404, detail=f"Template #{graph_id} not found.") raise HTTPException(status_code=404, detail=f"Template #{graph_id} not found.")
@ -510,7 +512,7 @@ async def get_template(graph_id: str, version: int | None = None) -> graph_db.Gr
) )
async def create_new_template( async def create_new_template(
create_graph: CreateGraph, user_id: Annotated[str, Depends(get_user_id)] create_graph: CreateGraph, user_id: Annotated[str, Depends(get_user_id)]
) -> graph_db.Graph: ) -> graph_db.GraphModel:
return await do_create_graph(create_graph, is_template=True, user_id=user_id) return await do_create_graph(create_graph, is_template=True, user_id=user_id)

View File

@ -4,14 +4,20 @@ import typing
import autogpt_libs.auth.depends import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware import autogpt_libs.auth.middleware
import fastapi import fastapi
import prisma
import backend.data.graph import backend.data.graph
import backend.integrations.creds_manager
import backend.integrations.webhooks.graph_lifecycle_hooks
import backend.server.v2.library.db import backend.server.v2.library.db
import backend.server.v2.library.model import backend.server.v2.library.model
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = fastapi.APIRouter() router = fastapi.APIRouter()
integration_creds_manager = (
backend.integrations.creds_manager.IntegrationCredentialsManager()
)
@router.get( @router.get(
@ -63,10 +69,53 @@ async def add_agent_to_library(
HTTPException: If there is an error adding the agent to the library HTTPException: If there is an error adding the agent to the library
""" """
try: try:
await backend.server.v2.library.db.add_agent_to_library( # Get the graph from the store listing
store_listing_version_id=store_listing_version_id, user_id=user_id store_listing_version = (
await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id}, include={"Agent": True}
) )
)
if not store_listing_version or not store_listing_version.Agent:
raise fastapi.HTTPException(
status_code=404,
detail=f"Store listing version {store_listing_version_id} not found",
)
agent = store_listing_version.Agent
if agent.userId == user_id:
raise fastapi.HTTPException(
status_code=400, detail="Cannot add own agent to library"
)
# Create a new graph from the template
graph = await backend.data.graph.get_graph(
agent.id, agent.version, template=True, user_id=user_id
)
if not graph:
raise fastapi.HTTPException(
status_code=404, detail=f"Agent {agent.id} not found"
)
# Create a deep copy with new IDs
graph.version = 1
graph.is_template = False
graph.is_active = True
graph.reassign_ids(user_id=user_id, reassign_graph_id=True)
# Save the new graph
graph = await backend.data.graph.create_graph(graph, user_id=user_id)
graph = (
await backend.integrations.webhooks.graph_lifecycle_hooks.on_graph_activate(
graph,
get_credentials=lambda id: integration_creds_manager.get(user_id, id),
)
)
return fastapi.Response(status_code=201) return fastapi.Response(status_code=201)
except Exception: except Exception:
logger.exception("Exception occurred whilst adding agent to library") logger.exception("Exception occurred whilst adding agent to library")
raise fastapi.HTTPException( raise fastapi.HTTPException(

View File

@ -2,6 +2,7 @@ import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware import autogpt_libs.auth.middleware
import fastapi import fastapi
import fastapi.testclient import fastapi.testclient
import pytest
import pytest_mock import pytest_mock
import backend.server.v2.library.db import backend.server.v2.library.db
@ -80,6 +81,7 @@ def test_get_library_agents_error(mocker: pytest_mock.MockFixture):
mock_db_call.assert_called_once_with("test-user-id") mock_db_call.assert_called_once_with("test-user-id")
@pytest.mark.skip(reason="Mocker Not implemented")
def test_add_agent_to_library_success(mocker: pytest_mock.MockFixture): def test_add_agent_to_library_success(mocker: pytest_mock.MockFixture):
mock_db_call = mocker.patch("backend.server.v2.library.db.add_agent_to_library") mock_db_call = mocker.patch("backend.server.v2.library.db.add_agent_to_library")
mock_db_call.return_value = None mock_db_call.return_value = None
@ -91,6 +93,7 @@ def test_add_agent_to_library_success(mocker: pytest_mock.MockFixture):
) )
@pytest.mark.skip(reason="Mocker Not implemented")
def test_add_agent_to_library_error(mocker: pytest_mock.MockFixture): def test_add_agent_to_library_error(mocker: pytest_mock.MockFixture):
mock_db_call = mocker.patch("backend.server.v2.library.db.add_agent_to_library") mock_db_call = mocker.patch("backend.server.v2.library.db.add_agent_to_library")
mock_db_call.side_effect = Exception("Test error") mock_db_call.side_effect = Exception("Test error")

View File

@ -31,7 +31,7 @@ async def get_store_agents(
sanitized_query = search_query.strip() sanitized_query = search_query.strip()
if not sanitized_query or len(sanitized_query) > 100: # Reasonable length limit if not sanitized_query or len(sanitized_query) > 100: # Reasonable length limit
raise backend.server.v2.store.exceptions.DatabaseError( raise backend.server.v2.store.exceptions.DatabaseError(
"Invalid search query" f"Invalid search query: len({len(sanitized_query)}) query: {search_query}"
) )
# Escape special SQL characters # Escape special SQL characters
@ -173,7 +173,7 @@ async def get_store_creators(
where = {} where = {}
if featured: if featured:
where["isFeatured"] = featured where["is_featured"] = featured
# Add search filter if provided, using parameterized queries # Add search filter if provided, using parameterized queries
if search_query: if search_query:
@ -449,6 +449,11 @@ async def create_store_submission(
) )
try: try:
# Sanitize slug to only allow letters and hyphens
slug = "".join(
c if c.isalpha() or c == "-" or c.isnumeric() else "" for c in slug
).lower()
# First verify the agent belongs to this user # First verify the agent belongs to this user
agent = await prisma.models.AgentGraph.prisma().find_first( agent = await prisma.models.AgentGraph.prisma().find_first(
where=prisma.types.AgentGraphWhereInput( where=prisma.types.AgentGraphWhereInput(
@ -578,22 +583,22 @@ async def get_user_profile(
if not profile: if not profile:
logger.warning(f"Profile not found for user {user_id}") logger.warning(f"Profile not found for user {user_id}")
await prisma.models.Profile.prisma().create( new_profile = await prisma.models.Profile.prisma().create(
data=prisma.types.ProfileCreateInput( data=prisma.types.ProfileCreateInput(
userId=user_id, userId=user_id,
name="No Profile Data", name="No Profile Data",
username=f"{random.choice(['happy', 'clever', 'swift', 'bright', 'wise'])}-{random.choice(['fox', 'wolf', 'bear', 'eagle', 'owl'])}_{random.randint(1000,9999)}", username=f"{random.choice(['happy', 'clever', 'swift', 'bright', 'wise'])}-{random.choice(['fox', 'wolf', 'bear', 'eagle', 'owl'])}_{random.randint(1000,9999)}".lower(),
description="No Profile Data", description="No Profile Data",
links=[], links=[],
avatarUrl="", avatarUrl="",
) )
) )
return backend.server.v2.store.model.ProfileDetails( return backend.server.v2.store.model.ProfileDetails(
name="No Profile Data", name=new_profile.name,
username="No Profile Data", username=new_profile.username,
description="No Profile Data", description=new_profile.description,
links=[], links=new_profile.links,
avatar_url="", avatar_url=new_profile.avatarUrl,
) )
return backend.server.v2.store.model.ProfileDetails( return backend.server.v2.store.model.ProfileDetails(
@ -620,6 +625,7 @@ async def update_or_create_profile(
""" """
Update the store profile for a user. Creates a new profile if one doesn't exist. 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. Only allows updating if the user_id matches the owning user.
If a field is None, it will not overwrite the existing value in the case of an update.
Args: Args:
user_id: ID of the authenticated user user_id: ID of the authenticated user
@ -630,27 +636,36 @@ async def update_or_create_profile(
Raises: Raises:
HTTPException: If user is not authorized to update this profile HTTPException: If user is not authorized to update this profile
DatabaseError: If profile cannot be updated due to database issues
""" """
logger.debug(f"Updating profile for user {user_id}") logger.info(f"Updating profile for user {user_id} data: {profile}")
try: try:
# Check if profile exists for user # Sanitize username to only allow letters and hyphens
username = "".join(
c if c.isalpha() or c == "-" or c.isnumeric() else ""
for c in profile.username
).lower()
existing_profile = await prisma.models.Profile.prisma().find_first( existing_profile = await prisma.models.Profile.prisma().find_first(
where={"userId": user_id} where={"userId": user_id}
) )
# If no profile exists, create a new one # If no profile exists, create a new one
if not existing_profile: if not existing_profile:
logger.debug(f"Creating new profile for user {user_id}") logger.debug(
f"No existing profile found. Creating new profile for user {user_id}"
)
# Create new profile since one doesn't exist # Create new profile since one doesn't exist
new_profile = await prisma.models.Profile.prisma().create( new_profile = await prisma.models.Profile.prisma().create(
data={ data={
"userId": user_id, "userId": user_id,
"name": profile.name, "name": profile.name,
"username": profile.username, "username": username,
"description": profile.description, "description": profile.description,
"links": profile.links, "links": profile.links or [],
"avatarUrl": profile.avatar_url, "avatarUrl": profile.avatar_url,
"isFeatured": False,
} }
) )
@ -666,16 +681,23 @@ async def update_or_create_profile(
) )
else: else:
logger.debug(f"Updating existing profile for user {user_id}") logger.debug(f"Updating existing profile for user {user_id}")
# Update only provided fields for the existing profile
update_data = {}
if profile.name is not None:
update_data["name"] = profile.name
if profile.username is not None:
update_data["username"] = username
if profile.description is not None:
update_data["description"] = profile.description
if profile.links is not None:
update_data["links"] = profile.links
if profile.avatar_url is not None:
update_data["avatarUrl"] = profile.avatar_url
# Update the existing profile # Update the existing profile
updated_profile = await prisma.models.Profile.prisma().update( updated_profile = await prisma.models.Profile.prisma().update(
where={"id": existing_profile.id}, where={"id": existing_profile.id},
data=prisma.types.ProfileUpdateInput( data=prisma.types.ProfileUpdateInput(**update_data),
name=profile.name,
username=profile.username,
description=profile.description,
links=profile.links,
avatarUrl=profile.avatar_url,
),
) )
if updated_profile is None: if updated_profile is None:
logger.error(f"Failed to update profile for user {user_id}") logger.error(f"Failed to update profile for user {user_id}")
@ -745,6 +767,7 @@ async def get_my_agents(
agent_version=agent.version, agent_version=agent.version,
agent_name=agent.name or "", agent_name=agent.name or "",
last_edited=agent.updatedAt or agent.createdAt, last_edited=agent.updatedAt or agent.createdAt,
description=agent.description or "",
) )
for agent in agents for agent in agents
] ]

View File

@ -0,0 +1,94 @@
import io
import logging
from enum import Enum
import replicate
import replicate.exceptions
import requests
from replicate.helpers import FileOutput
from backend.data.graph import Graph
from backend.util.settings import Settings
logger = logging.getLogger(__name__)
class ImageSize(str, Enum):
LANDSCAPE = "1024x768"
class ImageStyle(str, Enum):
DIGITAL_ART = "digital art"
async def generate_agent_image(agent: Graph) -> io.BytesIO:
"""
Generate an image for an agent using Flux model via Replicate API.
Args:
agent (Graph): The agent to generate an image for
Returns:
io.BytesIO: The generated image as bytes
"""
try:
settings = Settings()
if not settings.secrets.replicate_api_key:
raise ValueError("Missing Replicate API key in settings")
# Construct prompt from agent details
prompt = f"Create a visually engaging app store thumbnail for the AI agent that highlights what it does in a clear and captivating way:\n- **Name**: {agent.name}\n- **Description**: {agent.description}\nFocus on showcasing its core functionality with an appealing design."
# Set up Replicate client
client = replicate.Client(api_token=settings.secrets.replicate_api_key)
# Model parameters
input_data = {
"prompt": prompt,
"width": 1024,
"height": 768,
"aspect_ratio": "4:3",
"output_format": "jpg",
"output_quality": 90,
"num_inference_steps": 30,
"guidance": 3.5,
"negative_prompt": "blurry, low quality, distorted, deformed",
"disable_safety_checker": True,
}
try:
# Run model
output = client.run("black-forest-labs/flux-1.1-pro", input=input_data)
# Depending on the model output, extract the image URL or bytes
# If the output is a list of FileOutput or URLs
if isinstance(output, list) and output:
if isinstance(output[0], FileOutput):
image_bytes = output[0].read()
else:
# If it's a URL string, fetch the image bytes
result_url = output[0]
response = requests.get(result_url)
response.raise_for_status()
image_bytes = response.content
elif isinstance(output, FileOutput):
image_bytes = output.read()
elif isinstance(output, str):
# Output is a URL
response = requests.get(output)
response.raise_for_status()
image_bytes = response.content
else:
raise RuntimeError("Unexpected output format from the model.")
return io.BytesIO(image_bytes)
except replicate.exceptions.ReplicateError as e:
if e.status == 401:
raise RuntimeError("Invalid Replicate API token") from e
raise RuntimeError(f"Replicate API error: {str(e)}") from e
except Exception as e:
logger.exception("Failed to generate agent image")
raise RuntimeError(f"Image generation failed: {str(e)}")

View File

@ -15,7 +15,45 @@ ALLOWED_VIDEO_TYPES = {"video/mp4", "video/webm"}
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
async def upload_media(user_id: str, file: fastapi.UploadFile) -> str: async def check_media_exists(user_id: str, filename: str) -> str | None:
"""
Check if a media file exists in storage for the given user.
Tries both images and videos directories.
Args:
user_id (str): ID of the user who uploaded the file
filename (str): Name of the file to check
Returns:
str | None: URL of the blob if it exists, None otherwise
"""
try:
settings = Settings()
storage_client = storage.Client()
bucket = storage_client.bucket(settings.config.media_gcs_bucket_name)
# Check images
image_path = f"users/{user_id}/images/{filename}"
image_blob = bucket.blob(image_path)
if image_blob.exists():
return image_blob.public_url
# Check videos
video_path = f"users/{user_id}/videos/{filename}"
video_blob = bucket.blob(video_path)
if video_blob.exists():
return video_blob.public_url
return None
except Exception as e:
logger.error(f"Error checking if media file exists: {str(e)}")
return None
async def upload_media(
user_id: str, file: fastapi.UploadFile, use_file_name: bool = False
) -> str:
# Get file content for deeper validation # Get file content for deeper validation
try: try:
@ -84,6 +122,9 @@ async def upload_media(user_id: str, file: fastapi.UploadFile) -> str:
try: try:
# Validate file type # Validate file type
content_type = file.content_type content_type = file.content_type
if content_type is None:
content_type = "image/jpeg"
if ( if (
content_type not in ALLOWED_IMAGE_TYPES content_type not in ALLOWED_IMAGE_TYPES
and content_type not in ALLOWED_VIDEO_TYPES and content_type not in ALLOWED_VIDEO_TYPES
@ -119,6 +160,9 @@ async def upload_media(user_id: str, file: fastapi.UploadFile) -> str:
# Generate unique filename # Generate unique filename
filename = file.filename or "" filename = file.filename or ""
file_ext = os.path.splitext(filename)[1].lower() file_ext = os.path.splitext(filename)[1].lower()
if use_file_name:
unique_filename = filename
else:
unique_filename = f"{uuid.uuid4()}{file_ext}" unique_filename = f"{uuid.uuid4()}{file_ext}"
# Construct storage path # Construct storage path

View File

@ -24,6 +24,7 @@ class MyAgent(pydantic.BaseModel):
agent_id: str agent_id: str
agent_version: int agent_version: int
agent_name: str agent_name: str
description: str
last_edited: datetime.datetime last_edited: datetime.datetime
@ -99,7 +100,7 @@ class Profile(pydantic.BaseModel):
description: str description: str
links: list[str] links: list[str]
avatar_url: str avatar_url: str
is_featured: bool is_featured: bool = False
class StoreSubmission(pydantic.BaseModel): class StoreSubmission(pydantic.BaseModel):

View File

@ -1,12 +1,15 @@
import logging import logging
import typing import typing
import urllib.parse
import autogpt_libs.auth.depends import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware import autogpt_libs.auth.middleware
import fastapi import fastapi
import fastapi.responses import fastapi.responses
import backend.data.graph
import backend.server.v2.store.db import backend.server.v2.store.db
import backend.server.v2.store.image_gen
import backend.server.v2.store.media import backend.server.v2.store.media
import backend.server.v2.store.model import backend.server.v2.store.model
@ -20,12 +23,16 @@ router = fastapi.APIRouter()
############################################## ##############################################
@router.get("/profile", tags=["store", "private"]) @router.get(
"/profile",
tags=["store", "private"],
response_model=backend.server.v2.store.model.ProfileDetails,
)
async def get_profile( async def get_profile(
user_id: typing.Annotated[ user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
] ]
) -> backend.server.v2.store.model.ProfileDetails: ):
""" """
Get the profile details for the authenticated user. Get the profile details for the authenticated user.
""" """
@ -34,20 +41,24 @@ async def get_profile(
return profile return profile
except Exception: except Exception:
logger.exception("Exception occurred whilst getting user profile") logger.exception("Exception occurred whilst getting user profile")
raise return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving the user profile"},
)
@router.post( @router.post(
"/profile", "/profile",
tags=["store", "private"], tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.CreatorDetails,
) )
async def update_or_create_profile( async def update_or_create_profile(
profile: backend.server.v2.store.model.Profile, profile: backend.server.v2.store.model.Profile,
user_id: typing.Annotated[ user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
], ],
) -> backend.server.v2.store.model.CreatorDetails: ):
""" """
Update the store profile for the authenticated user. Update the store profile for the authenticated user.
@ -68,7 +79,10 @@ async def update_or_create_profile(
return updated_profile return updated_profile
except Exception: except Exception:
logger.exception("Exception occurred whilst updating profile") logger.exception("Exception occurred whilst updating profile")
raise return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while updating the user profile"},
)
############################################## ##############################################
@ -76,7 +90,11 @@ async def update_or_create_profile(
############################################## ##############################################
@router.get("/agents", tags=["store", "public"]) @router.get(
"/agents",
tags=["store", "public"],
response_model=backend.server.v2.store.model.StoreAgentsResponse,
)
async def get_agents( async def get_agents(
featured: bool = False, featured: bool = False,
creator: str | None = None, creator: str | None = None,
@ -85,7 +103,7 @@ async def get_agents(
category: str | None = None, category: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
) -> backend.server.v2.store.model.StoreAgentsResponse: ):
""" """
Get a paginated list of agents from the store with optional filtering and sorting. Get a paginated list of agents from the store with optional filtering and sorting.
@ -135,32 +153,46 @@ async def get_agents(
return agents return agents
except Exception: except Exception:
logger.exception("Exception occured whilst getting store agents") logger.exception("Exception occured whilst getting store agents")
raise return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving the store agents"},
)
@router.get("/agents/{username}/{agent_name}", tags=["store", "public"]) @router.get(
async def get_agent( "/agents/{username}/{agent_name}",
username: str, agent_name: str tags=["store", "public"],
) -> backend.server.v2.store.model.StoreAgentDetails: response_model=backend.server.v2.store.model.StoreAgentDetails,
)
async def get_agent(username: str, agent_name: str):
""" """
This is only used on the AgentDetails Page This is only used on the AgentDetails Page
It returns the store listing agents details. It returns the store listing agents details.
""" """
try: try:
username = urllib.parse.unquote(username).lower()
# URL decode the agent name since it comes from the URL path
agent_name = urllib.parse.unquote(agent_name).lower()
agent = await backend.server.v2.store.db.get_store_agent_details( agent = await backend.server.v2.store.db.get_store_agent_details(
username=username, agent_name=agent_name username=username, agent_name=agent_name
) )
return agent return agent
except Exception: except Exception:
logger.exception("Exception occurred whilst getting store agent details") logger.exception("Exception occurred whilst getting store agent details")
raise return fastapi.responses.JSONResponse(
status_code=500,
content={
"detail": "An error occurred while retrieving the store agent details"
},
)
@router.post( @router.post(
"/agents/{username}/{agent_name}/review", "/agents/{username}/{agent_name}/review",
tags=["store"], tags=["store"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.StoreReview,
) )
async def create_review( async def create_review(
username: str, username: str,
@ -169,7 +201,7 @@ async def create_review(
user_id: typing.Annotated[ user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
], ],
) -> backend.server.v2.store.model.StoreReview: ):
""" """
Create a review for a store agent. Create a review for a store agent.
@ -183,6 +215,8 @@ async def create_review(
The created review The created review
""" """
try: try:
username = urllib.parse.unquote(username).lower()
agent_name = urllib.parse.unquote(agent_name)
# Create the review # Create the review
created_review = await backend.server.v2.store.db.create_store_review( created_review = await backend.server.v2.store.db.create_store_review(
user_id=user_id, user_id=user_id,
@ -194,7 +228,10 @@ async def create_review(
return created_review return created_review
except Exception: except Exception:
logger.exception("Exception occurred whilst creating store review") logger.exception("Exception occurred whilst creating store review")
raise return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while creating the store review"},
)
############################################## ##############################################
@ -202,14 +239,18 @@ async def create_review(
############################################## ##############################################
@router.get("/creators", tags=["store", "public"]) @router.get(
"/creators",
tags=["store", "public"],
response_model=backend.server.v2.store.model.CreatorsResponse,
)
async def get_creators( async def get_creators(
featured: bool = False, featured: bool = False,
search_query: str | None = None, search_query: str | None = None,
sorted_by: str | None = None, sorted_by: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
) -> backend.server.v2.store.model.CreatorsResponse: ):
""" """
This is needed for: This is needed for:
- Home Page Featured Creators - Home Page Featured Creators
@ -243,23 +284,38 @@ async def get_creators(
return creators return creators
except Exception: except Exception:
logger.exception("Exception occurred whilst getting store creators") logger.exception("Exception occurred whilst getting store creators")
raise return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving the store creators"},
)
@router.get("/creator/{username}", tags=["store", "public"]) @router.get(
async def get_creator(username: str) -> backend.server.v2.store.model.CreatorDetails: "/creator/{username}",
tags=["store", "public"],
response_model=backend.server.v2.store.model.CreatorDetails,
)
async def get_creator(
username: str,
):
""" """
Get the details of a creator Get the details of a creator
- Creator Details Page - Creator Details Page
""" """
try: try:
username = urllib.parse.unquote(username).lower()
creator = await backend.server.v2.store.db.get_store_creator_details( creator = await backend.server.v2.store.db.get_store_creator_details(
username=username username=username.lower()
) )
return creator return creator
except Exception: except Exception:
logger.exception("Exception occurred whilst getting creator details") logger.exception("Exception occurred whilst getting creator details")
raise return fastapi.responses.JSONResponse(
status_code=500,
content={
"detail": "An error occurred while retrieving the creator details"
},
)
############################################ ############################################
@ -269,31 +325,36 @@ async def get_creator(username: str) -> backend.server.v2.store.model.CreatorDet
"/myagents", "/myagents",
tags=["store", "private"], tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.MyAgentsResponse,
) )
async def get_my_agents( async def get_my_agents(
user_id: typing.Annotated[ user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
] ]
) -> backend.server.v2.store.model.MyAgentsResponse: ):
try: try:
agents = await backend.server.v2.store.db.get_my_agents(user_id) agents = await backend.server.v2.store.db.get_my_agents(user_id)
return agents return agents
except Exception: except Exception:
logger.exception("Exception occurred whilst getting my agents") logger.exception("Exception occurred whilst getting my agents")
raise return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving the my agents"},
)
@router.delete( @router.delete(
"/submissions/{submission_id}", "/submissions/{submission_id}",
tags=["store", "private"], tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=bool,
) )
async def delete_submission( async def delete_submission(
user_id: typing.Annotated[ user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
], ],
submission_id: str, submission_id: str,
) -> bool: ):
""" """
Delete a store listing submission. Delete a store listing submission.
@ -312,13 +373,17 @@ async def delete_submission(
return result return result
except Exception: except Exception:
logger.exception("Exception occurred whilst deleting store submission") logger.exception("Exception occurred whilst deleting store submission")
raise return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while deleting the store submission"},
)
@router.get( @router.get(
"/submissions", "/submissions",
tags=["store", "private"], tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.StoreSubmissionsResponse,
) )
async def get_submissions( async def get_submissions(
user_id: typing.Annotated[ user_id: typing.Annotated[
@ -326,7 +391,7 @@ async def get_submissions(
], ],
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
) -> backend.server.v2.store.model.StoreSubmissionsResponse: ):
""" """
Get a paginated list of store submissions for the authenticated user. Get a paginated list of store submissions for the authenticated user.
@ -359,20 +424,26 @@ async def get_submissions(
return listings return listings
except Exception: except Exception:
logger.exception("Exception occurred whilst getting store submissions") logger.exception("Exception occurred whilst getting store submissions")
raise return fastapi.responses.JSONResponse(
status_code=500,
content={
"detail": "An error occurred while retrieving the store submissions"
},
)
@router.post( @router.post(
"/submissions", "/submissions",
tags=["store", "private"], tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.StoreSubmission,
) )
async def create_submission( async def create_submission(
submission_request: backend.server.v2.store.model.StoreSubmissionRequest, submission_request: backend.server.v2.store.model.StoreSubmissionRequest,
user_id: typing.Annotated[ user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
], ],
) -> backend.server.v2.store.model.StoreSubmission: ):
""" """
Create a new store listing submission. Create a new store listing submission.
@ -402,7 +473,10 @@ async def create_submission(
return submission return submission
except Exception: except Exception:
logger.exception("Exception occurred whilst creating store submission") logger.exception("Exception occurred whilst creating store submission")
raise return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while creating the store submission"},
)
@router.post( @router.post(
@ -415,7 +489,7 @@ async def upload_submission_media(
user_id: typing.Annotated[ user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
], ],
) -> str: ):
""" """
Upload media (images/videos) for a store listing submission. Upload media (images/videos) for a store listing submission.
@ -434,8 +508,70 @@ async def upload_submission_media(
user_id=user_id, file=file user_id=user_id, file=file
) )
return media_url return media_url
except Exception as e: except Exception:
logger.exception("Exception occurred whilst uploading submission media") logger.exception("Exception occurred whilst uploading submission media")
raise fastapi.HTTPException( return fastapi.responses.JSONResponse(
status_code=500, detail=f"Failed to upload media file: {str(e)}" status_code=500,
content={"detail": "An error occurred while uploading the media file"},
)
@router.post(
"/submissions/generate_image",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
)
async def generate_image(
agent_id: str,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> fastapi.responses.Response:
"""
Generate an image for a store listing submission.
Args:
agent_id (str): ID of the agent to generate an image for
user_id (str): ID of the authenticated user
Returns:
JSONResponse: JSON containing the URL of the generated image
"""
try:
agent = await backend.data.graph.get_graph(agent_id, user_id=user_id)
if not agent:
raise fastapi.HTTPException(
status_code=404, detail=f"Agent with ID {agent_id} not found"
)
# Use .jpeg here since we are generating JPEG images
filename = f"agent_{agent_id}.jpeg"
existing_url = await backend.server.v2.store.media.check_media_exists(
user_id, filename
)
if existing_url:
logger.info(f"Using existing image for agent {agent_id}")
return fastapi.responses.JSONResponse(content={"image_url": existing_url})
# Generate agent image as JPEG
image = await backend.server.v2.store.image_gen.generate_agent_image(
agent=agent
)
# Create UploadFile with the correct filename and content_type
image_file = fastapi.UploadFile(
file=image,
filename=filename,
)
image_url = await backend.server.v2.store.media.upload_media(
user_id=user_id, file=image_file, use_file_name=True
)
return fastapi.responses.JSONResponse(content={"image_url": image_url})
except Exception:
logger.exception("Exception occurred whilst generating submission image")
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while generating the image"},
) )

View File

@ -12,6 +12,7 @@ from backend.util.settings import Config
# List of IP networks to block # List of IP networks to block
BLOCKED_IP_NETWORKS = [ BLOCKED_IP_NETWORKS = [
# --8<-- [start:BLOCKED_IP_NETWORKS] # --8<-- [start:BLOCKED_IP_NETWORKS]
# IPv4 Ranges
ipaddress.ip_network("0.0.0.0/8"), # "This" Network ipaddress.ip_network("0.0.0.0/8"), # "This" Network
ipaddress.ip_network("10.0.0.0/8"), # Private-Use ipaddress.ip_network("10.0.0.0/8"), # Private-Use
ipaddress.ip_network("127.0.0.0/8"), # Loopback ipaddress.ip_network("127.0.0.0/8"), # Loopback
@ -20,6 +21,11 @@ BLOCKED_IP_NETWORKS = [
ipaddress.ip_network("192.168.0.0/16"), # Private-Use ipaddress.ip_network("192.168.0.0/16"), # Private-Use
ipaddress.ip_network("224.0.0.0/4"), # Multicast ipaddress.ip_network("224.0.0.0/4"), # Multicast
ipaddress.ip_network("240.0.0.0/4"), # Reserved for Future Use ipaddress.ip_network("240.0.0.0/4"), # Reserved for Future Use
# IPv6 Ranges
ipaddress.ip_network("::1/128"), # Loopback
ipaddress.ip_network("fc00::/7"), # Unique local addresses (ULA)
ipaddress.ip_network("fe80::/10"), # Link-local
ipaddress.ip_network("ff00::/8"), # Multicast
# --8<-- [end:BLOCKED_IP_NETWORKS] # --8<-- [end:BLOCKED_IP_NETWORKS]
] ]
@ -27,18 +33,6 @@ ALLOWED_SCHEMES = ["http", "https"]
HOSTNAME_REGEX = re.compile(r"^[A-Za-z0-9.-]+$") # Basic DNS-safe hostname pattern HOSTNAME_REGEX = re.compile(r"^[A-Za-z0-9.-]+$") # Basic DNS-safe hostname pattern
def _canonicalize_url(url: str) -> str:
# Strip spaces and trailing slashes
url = url.strip().strip("/")
# Ensure the URL starts with http:// or https://
if not url.startswith(("http://", "https://")):
url = "http://" + url
# Replace backslashes with forward slashes to avoid parsing ambiguities
url = url.replace("\\", "/")
return url
def _is_ip_blocked(ip: str) -> bool: def _is_ip_blocked(ip: str) -> bool:
""" """
Checks if the IP address is in a blocked network. Checks if the IP address is in a blocked network.
@ -49,10 +43,15 @@ def _is_ip_blocked(ip: str) -> bool:
def validate_url(url: str, trusted_origins: list[str]) -> str: def validate_url(url: str, trusted_origins: list[str]) -> str:
""" """
Validates the URL to prevent SSRF attacks by ensuring it does not point to a private Validates the URL to prevent SSRF attacks by ensuring it does not point
or untrusted IP address, unless whitelisted. to a private, link-local, or otherwise blocked IP address unless
the hostname is explicitly trusted.
""" """
url = _canonicalize_url(url) # Canonicalize URL
url = url.strip("/ ").replace("\\", "/")
parsed = urlparse(url)
if not parsed.scheme:
url = f"http://{url}"
parsed = urlparse(url) parsed = urlparse(url)
# Check scheme # Check scheme
@ -61,7 +60,7 @@ def validate_url(url: str, trusted_origins: list[str]) -> str:
f"Scheme '{parsed.scheme}' is not allowed. Only HTTP/HTTPS are supported." f"Scheme '{parsed.scheme}' is not allowed. Only HTTP/HTTPS are supported."
) )
# Validate and IDNA encode the hostname # Validate and IDNA encode hostname
if not parsed.hostname: if not parsed.hostname:
raise ValueError("Invalid URL: No hostname found.") raise ValueError("Invalid URL: No hostname found.")
@ -75,11 +74,11 @@ def validate_url(url: str, trusted_origins: list[str]) -> str:
if not HOSTNAME_REGEX.match(ascii_hostname): if not HOSTNAME_REGEX.match(ascii_hostname):
raise ValueError("Hostname contains invalid characters.") raise ValueError("Hostname contains invalid characters.")
# Rebuild the URL with the normalized, IDNA-encoded hostname # Rebuild URL with IDNA-encoded hostname
parsed = parsed._replace(netloc=ascii_hostname) parsed = parsed._replace(netloc=ascii_hostname)
url = str(urlunparse(parsed)) url = str(urlunparse(parsed))
# Check if hostname is a trusted origin (exact match) # If hostname is trusted, skip IP-based checks
if ascii_hostname in trusted_origins: if ascii_hostname in trusted_origins:
return url return url
@ -92,11 +91,12 @@ def validate_url(url: str, trusted_origins: list[str]) -> str:
if not ip_addresses: if not ip_addresses:
raise ValueError(f"No IP addresses found for {ascii_hostname}") raise ValueError(f"No IP addresses found for {ascii_hostname}")
# Check if any resolved IP address falls into blocked ranges # Block any IP address that belongs to a blocked range
for ip in ip_addresses: for ip_str in ip_addresses:
if _is_ip_blocked(ip): if _is_ip_blocked(ip_str):
raise ValueError( raise ValueError(
f"Access to private IP address {ip} for hostname {ascii_hostname} is not allowed." f"Access to blocked or private IP address {ip_str} "
f"for hostname {ascii_hostname} is not allowed."
) )
return url return url
@ -104,7 +104,9 @@ def validate_url(url: str, trusted_origins: list[str]) -> str:
class Requests: class Requests:
""" """
A wrapper around the requests library that validates URLs before making requests. A wrapper around the requests library that validates URLs before
making requests, preventing SSRF by blocking private networks and
other disallowed address spaces.
""" """
def __init__( def __init__(
@ -128,13 +130,16 @@ class Requests:
def request( def request(
self, method, url, headers=None, allow_redirects=False, *args, **kwargs self, method, url, headers=None, allow_redirects=False, *args, **kwargs
) -> req.Response: ) -> req.Response:
# Merge any extra headers
if self.extra_headers is not None: if self.extra_headers is not None:
headers = {**(headers or {}), **self.extra_headers} headers = {**(headers or {}), **self.extra_headers}
# Validate the URL (with optional extra validator)
url = validate_url(url, self.trusted_origins) url = validate_url(url, self.trusted_origins)
if self.extra_url_validator is not None: if self.extra_url_validator is not None:
url = self.extra_url_validator(url) url = self.extra_url_validator(url)
# Perform the request
response = req.request( response = req.request(
method, method,
url, url,

View File

@ -153,6 +153,11 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
description="The name of the Google Cloud Storage bucket for media files", description="The name of the Google Cloud Storage bucket for media files",
) )
scheduler_db_pool_size: int = Field(
default=3,
description="The pool size for the scheduler database connection pool",
)
@field_validator("platform_base_url", "frontend_base_url") @field_validator("platform_base_url", "frontend_base_url")
@classmethod @classmethod
def validate_platform_base_url(cls, v: str, info: ValidationInfo) -> str: def validate_platform_base_url(cls, v: str, info: ValidationInfo) -> str:

View File

@ -0,0 +1,50 @@
BEGIN;
DROP VIEW IF EXISTS "StoreAgent";
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,
slv.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", slv.name, slv."videoUrl", slv."imageUrls", slv."isFeatured",
p.username, p."avatarUrl", slv."subHeading", slv.description, slv.categories,
ar.run_count, rs.avg_rating;
COMMIT;

View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
[[package]] [[package]]
name = "aio-pika" name = "aio-pika"
@ -579,21 +579,6 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
[[package]]
name = "croniter"
version = "5.0.1"
description = "croniter provides iteration for datetime object with cron like format"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.6"
files = [
{file = "croniter-5.0.1-py2.py3-none-any.whl", hash = "sha256:eb28439742291f6c10b181df1a5ecf421208b1fc62ef44501daec1780a0b09e9"},
{file = "croniter-5.0.1.tar.gz", hash = "sha256:7d9b1ef25b10eece48fdf29d8ac52f9b6252abff983ac614ade4f3276294019e"},
]
[package.dependencies]
python-dateutil = "*"
pytz = ">2021.1"
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "43.0.3" version = "43.0.3"
@ -3014,17 +2999,6 @@ files = [
{file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"},
] ]
[[package]]
name = "pytz"
version = "2024.2"
description = "World timezone definitions, modern and historical"
optional = false
python-versions = "*"
files = [
{file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"},
{file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"},
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.2" version = "6.0.2"
@ -4339,4 +4313,4 @@ type = ["pytest-mypy"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10,<3.13" python-versions = ">=3.10,<3.13"
content-hash = "1f7b98944844245f1574339d622e8f1ecddf08aa1e9efa84682fd51ab0556a75" content-hash = "9325d134cf2c4390c520b9c5e8bae290a0fa05e0c782aa6b1f079d31d9a5c8f5"

View File

@ -14,7 +14,6 @@ anthropic = "^0.40.0"
apscheduler = "^3.11.0" apscheduler = "^3.11.0"
autogpt-libs = { path = "../autogpt_libs", develop = true } autogpt-libs = { path = "../autogpt_libs", develop = true }
click = "^8.1.7" click = "^8.1.7"
croniter = "^5.0.1"
discord-py = "^2.4.0" discord-py = "^2.4.0"
e2b-code-interpreter = "^1.0.1" e2b-code-interpreter = "^1.0.1"
fastapi = "^0.115.5" fastapi = "^0.115.5"

View File

@ -90,7 +90,12 @@ async def test_get_input_schema(server: SpinTestServer):
Node( Node(
id="node_0_a", id="node_0_a",
block_id=input_block, block_id=input_block,
input_default={"name": "in_key_a", "title": "Key A", "value": "A"}, input_default={
"name": "in_key_a",
"title": "Key A",
"value": "A",
"advanced": True,
},
metadata={"id": "node_0_a"}, metadata={"id": "node_0_a"},
), ),
Node( Node(
@ -138,8 +143,8 @@ async def test_get_input_schema(server: SpinTestServer):
) )
class ExpectedInputSchema(BlockSchema): class ExpectedInputSchema(BlockSchema):
in_key_a: Any = SchemaField(title="Key A", default="A", advanced=False) in_key_a: Any = SchemaField(title="Key A", default="A", advanced=True)
in_key_b: Any = SchemaField(title="in_key_b", advanced=True) in_key_b: Any = SchemaField(title="in_key_b", advanced=False)
class ExpectedOutputSchema(BlockSchema): class ExpectedOutputSchema(BlockSchema):
out_key: Any = SchemaField( out_key: Any = SchemaField(

View File

@ -82,7 +82,7 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^3.2.2", "@chromatic-com/storybook": "^3.2.3",
"@playwright/test": "^1.48.2", "@playwright/test": "^1.48.2",
"@storybook/addon-a11y": "^8.3.5", "@storybook/addon-a11y": "^8.3.5",
"@storybook/addon-essentials": "^8.4.2", "@storybook/addon-essentials": "^8.4.2",
@ -93,7 +93,7 @@
"@storybook/nextjs": "^8.4.2", "@storybook/nextjs": "^8.4.2",
"@storybook/react": "^8.3.5", "@storybook/react": "^8.3.5",
"@storybook/test": "^8.3.5", "@storybook/test": "^8.3.5",
"@storybook/test-runner": "^0.20.1", "@storybook/test-runner": "^0.21.0",
"@types/negotiator": "^0.6.3", "@types/negotiator": "^0.6.3",
"@types/node": "^22.9.0", "@types/node": "^22.9.0",
"@types/react": "^18", "@types/react": "^18",
@ -101,9 +101,9 @@
"@types/react-modal": "^3.16.3", "@types/react-modal": "^3.16.3",
"axe-playwright": "^2.0.3", "axe-playwright": "^2.0.3",
"chromatic": "^11.12.5", "chromatic": "^11.12.5",
"concurrently": "^9.0.1", "concurrently": "^9.1.1",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "15.1.0", "eslint-config-next": "15.1.3",
"eslint-plugin-storybook": "^0.11.0", "eslint-plugin-storybook": "^0.11.0",
"msw": "^2.7.0", "msw": "^2.7.0",
"msw-storybook-addon": "^2.0.3", "msw-storybook-addon": "^2.0.3",
@ -111,7 +111,7 @@
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.9", "prettier-plugin-tailwindcss": "^0.6.9",
"storybook": "^8.4.5", "storybook": "^8.4.5",
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.17",
"typescript": "^5" "typescript": "^5"
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",

View File

@ -37,7 +37,7 @@ export default async function RootLayout({
<Navbar <Navbar
links={[ links={[
{ {
name: "Agent Store", name: "Marketplace",
href: "/store", href: "/store",
}, },
{ {
@ -91,7 +91,7 @@ export default async function RootLayout({
}, },
]} ]}
/> />
<main className="flex-1 p-4">{children}</main> <main className="flex-1">{children}</main>
<TallyPopupSimple /> <TallyPopupSimple />
</div> </div>
<Toaster /> <Toaster />

View File

@ -5,11 +5,7 @@ import { z } from "zod";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import getServerSupabase from "@/lib/supabase/getServerSupabase"; import getServerSupabase from "@/lib/supabase/getServerSupabase";
import BackendAPI from "@/lib/autogpt-server-api"; import BackendAPI from "@/lib/autogpt-server-api";
import { loginFormSchema, LoginProvider } from "@/types/auth";
const loginFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
});
export async function logout() { export async function logout() {
return await Sentry.withServerActionInstrumentation( return await Sentry.withServerActionInstrumentation(
@ -25,7 +21,7 @@ export async function logout() {
const { error } = await supabase.auth.signOut(); const { error } = await supabase.auth.signOut();
if (error) { if (error) {
console.log("Error logging out", error); console.error("Error logging out", error);
return error.message; return error.message;
} }
@ -47,18 +43,13 @@ export async function login(values: z.infer<typeof loginFormSchema>) {
// We are sure that the values are of the correct type because zod validates the form // We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signInWithPassword(values); const { data, error } = await supabase.auth.signInWithPassword(values);
await api.createUser();
if (error) { if (error) {
console.log("Error logging in", error); console.error("Error logging in", error);
if (error.status == 400) {
// Hence User is not present
redirect("/login");
}
return error.message; return error.message;
} }
await api.createUser();
if (data.session) { if (data.session) {
await supabase.auth.setSession(data.session); await supabase.auth.setSession(data.session);
} }
@ -68,38 +59,34 @@ export async function login(values: z.infer<typeof loginFormSchema>) {
}); });
} }
export async function signup(values: z.infer<typeof loginFormSchema>) { export async function providerLogin(provider: LoginProvider) {
"use server";
return await Sentry.withServerActionInstrumentation( return await Sentry.withServerActionInstrumentation(
"signup", "providerLogin",
{}, {},
async () => { async () => {
const supabase = getServerSupabase(); const supabase = getServerSupabase();
const api = new BackendAPI();
if (!supabase) { if (!supabase) {
redirect("/error"); redirect("/error");
} }
// We are sure that the values are of the correct type because zod validates the form const { error } = await supabase!.auth.signInWithOAuth({
const { data, error } = await supabase.auth.signUp(values); provider: provider,
options: {
redirectTo:
process.env.AUTH_CALLBACK_URL ??
`http://localhost:3000/auth/callback`,
},
});
if (error) { if (error) {
console.log("Error signing up", error); console.error("Error logging in", 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; return error.message;
} }
if (data.session) { await api.createUser();
await supabase.auth.setSession(data.session); console.log("Logged in");
}
console.log("Signed up");
revalidatePath("/", "layout");
redirect("/store/profile");
}, },
); );
} }

View File

@ -1,10 +1,8 @@
"use client"; "use client";
import { login, signup } from "./actions"; import { login, providerLogin } from "./actions";
import { Button } from "@/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@ -14,40 +12,69 @@ import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { PasswordInput } from "@/components/PasswordInput"; import { useCallback, useState } from "react";
import { FaGoogle, FaGithub, FaDiscord, FaSpinner } from "react-icons/fa";
import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Checkbox } from "@/components/ui/checkbox";
import useSupabase from "@/hooks/useSupabase"; import useSupabase from "@/hooks/useSupabase";
import Spinner from "@/components/Spinner"; import Spinner from "@/components/Spinner";
import { useBackendAPI } from "@/lib/autogpt-server-api/context"; import {
AuthCard,
const loginFormSchema = z.object({ AuthHeader,
email: z.string().email().min(2).max(64), AuthButton,
password: z.string().min(6).max(64), AuthFeedback,
agreeToTerms: z.boolean().refine((value) => value === true, { AuthBottomText,
message: "You must agree to the Terms of Use and Privacy Policy", PasswordInput,
}), } from "@/components/auth";
}); import { loginFormSchema } from "@/types/auth";
export default function LoginPage() { export default function LoginPage() {
const { supabase, user, isUserLoading } = useSupabase(); const { supabase, user, isUserLoading } = useSupabase();
const [feedback, setFeedback] = useState<string | null>(null); const [feedback, setFeedback] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const api = useBackendAPI();
const form = useForm<z.infer<typeof loginFormSchema>>({ const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema), resolver: zodResolver(loginFormSchema),
defaultValues: { defaultValues: {
email: "", email: "",
password: "", password: "",
agreeToTerms: false,
}, },
}); });
// TODO: uncomment when we enable social login
// const onProviderLogin = useCallback(async (
// provider: LoginProvider,
// ) => {
// setIsLoading(true);
// const error = await providerLogin(provider);
// setIsLoading(false);
// if (error) {
// setFeedback(error);
// return;
// }
// setFeedback(null);
// }, [supabase]);
const onLogin = useCallback(
async (data: z.infer<typeof loginFormSchema>) => {
setIsLoading(true);
if (!(await form.trigger())) {
setIsLoading(false);
return;
}
const error = await login(data);
setIsLoading(false);
if (error) {
setFeedback(error);
return;
}
setFeedback(null);
},
[form],
);
if (user) { if (user) {
console.debug("User exists, redirecting to /"); console.debug("User exists, redirecting to /");
router.push("/"); router.push("/");
@ -65,84 +92,19 @@ export default function LoginPage() {
); );
} }
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`,
},
});
await api.createUser();
if (!error) {
setFeedback(null);
return;
}
setFeedback(error.message);
}
const onLogin = async (data: z.infer<typeof loginFormSchema>) => {
setIsLoading(true);
const error = await login(data);
setIsLoading(false);
if (error) {
setFeedback(error);
return;
}
setFeedback(null);
};
return ( return (
<div className="flex h-[80vh] items-center justify-center"> <AuthCard>
<div className="w-full max-w-md space-y-6 rounded-lg p-8 shadow-md"> <AuthHeader>Login to your account</AuthHeader>
<h1 className="text-lg font-medium">Log in to your 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 {...form}>
<form onSubmit={form.handleSubmit(onLogin)}> <form onSubmit={form.handleSubmit(onLogin)}>
<FormField <FormField
control={form.control} control={form.control}
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem className="mb-4"> <FormItem className="mb-6">
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input placeholder="user@email.com" {...field} /> <Input placeholder="m@example.com" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -152,92 +114,38 @@ export default function LoginPage() {
control={form.control} control={form.control}
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="mb-6">
<FormLabel>Password</FormLabel> <FormLabel className="flex w-full items-center justify-between">
<FormControl> <span>Password</span>
<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 <Link
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf" href="/reset_password"
className="underline" className="text-sm font-normal leading-normal text-black 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"
type="submit"
disabled={isLoading}
onClick={async () => {
setIsLoading(true);
const values = form.getValues();
const result = await login(values);
if (result) {
setFeedback(result);
}
setIsLoading(false);
}}
>
{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>
</form>
<p className="text-sm text-red-500">{feedback}</p>
</Form>
<Link href="/reset_password" className="text-sm">
Forgot your password? Forgot your password?
</Link> </Link>
</div> </FormLabel>
</div> <FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<AuthButton
onClick={() => onLogin(form.getValues())}
isLoading={isLoading}
type="submit"
>
Login
</AuthButton>
</form>
<AuthFeedback message={feedback} isError={true} />
</Form>
<AuthBottomText
text="Don't have an account?"
linkText="Sign up"
href="/signup"
/>
</AuthCard>
); );
} }

View File

@ -0,0 +1,7 @@
"use client";
import { redirect } from "next/navigation";
export default function Page() {
redirect("/store");
}

View File

@ -0,0 +1,60 @@
"use server";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { redirect } from "next/navigation";
import * as Sentry from "@sentry/nextjs";
import { headers } from "next/headers";
export async function sendResetEmail(email: string) {
return await Sentry.withServerActionInstrumentation(
"sendResetEmail",
{},
async () => {
const supabase = getServerSupabase();
const headersList = headers();
const host = headersList.get("host");
const protocol =
process.env.NODE_ENV === "development" ? "http" : "https";
const origin = `${protocol}://${host}`;
if (!supabase) {
redirect("/error");
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/reset_password`,
});
if (error) {
console.error("Error sending reset email", error);
return error.message;
}
console.log("Reset email sent");
redirect("/reset_password");
},
);
}
export async function changePassword(password: string) {
return await Sentry.withServerActionInstrumentation(
"changePassword",
{},
async () => {
const supabase = getServerSupabase();
if (!supabase) {
redirect("/error");
}
const { error } = await supabase.auth.updateUser({ password });
if (error) {
console.error("Error changing password", error);
return error.message;
}
await supabase.auth.signOut();
redirect("/login");
},
);
}

View File

@ -1,8 +1,15 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import {
AuthCard,
AuthHeader,
AuthButton,
AuthFeedback,
PasswordInput,
} from "@/components/auth";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@ -10,54 +17,87 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import useSupabase from "@/hooks/useSupabase"; import useSupabase from "@/hooks/useSupabase";
import { sendEmailFormSchema, changePasswordFormSchema } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation"; import { useCallback, useState } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { FaSpinner } from "react-icons/fa";
import { z } from "zod"; import { z } from "zod";
import { changePassword, sendResetEmail } from "./actions";
const emailFormSchema = z.object({ import Spinner from "@/components/Spinner";
email: z.string().email().min(2).max(64),
});
const resetPasswordFormSchema = z
.object({
password: z.string().min(6).max(64),
confirmPassword: z.string().min(6).max(64),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
const { supabase, user, isUserLoading } = useSupabase(); const { supabase, user, isUserLoading } = useSupabase();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null); const [feedback, setFeedback] = useState<string | null>(null);
const [isError, setIsError] = useState(false);
const [disabled, setDisabled] = useState(false);
const emailForm = useForm<z.infer<typeof emailFormSchema>>({ const sendEmailForm = useForm<z.infer<typeof sendEmailFormSchema>>({
resolver: zodResolver(emailFormSchema), resolver: zodResolver(sendEmailFormSchema),
defaultValues: { defaultValues: {
email: "", email: "",
}, },
}); });
const resetPasswordForm = useForm<z.infer<typeof resetPasswordFormSchema>>({ const changePasswordForm = useForm<z.infer<typeof changePasswordFormSchema>>({
resolver: zodResolver(resetPasswordFormSchema), resolver: zodResolver(changePasswordFormSchema),
defaultValues: { defaultValues: {
password: "", password: "",
confirmPassword: "", confirmPassword: "",
}, },
}); });
if (isUserLoading) { const onSendEmail = useCallback(
return ( async (data: z.infer<typeof sendEmailFormSchema>) => {
<div className="flex h-[80vh] items-center justify-center"> setIsLoading(true);
<FaSpinner className="mr-2 h-16 w-16 animate-spin" /> setFeedback(null);
</div>
if (!(await sendEmailForm.trigger())) {
setIsLoading(false);
return;
}
const error = await sendResetEmail(data.email);
setIsLoading(false);
if (error) {
setFeedback(error);
setIsError(true);
return;
}
setDisabled(true);
setFeedback(
"Password reset email sent if user exists. Please check your email.",
); );
setIsError(false);
},
[sendEmailForm],
);
const onChangePassword = useCallback(
async (data: z.infer<typeof changePasswordFormSchema>) => {
setIsLoading(true);
setFeedback(null);
if (!(await changePasswordForm.trigger())) {
setIsLoading(false);
return;
}
const error = await changePassword(data.password);
setIsLoading(false);
if (error) {
setFeedback(error);
setIsError(true);
return;
}
setFeedback("Password changed successfully. Redirecting to login.");
setIsError(false);
},
[changePasswordForm],
);
if (isUserLoading) {
return <Spinner />;
} }
if (!supabase) { if (!supabase) {
@ -68,147 +108,79 @@ export default function ResetPasswordPage() {
); );
} }
async function onSendEmail(d: z.infer<typeof emailFormSchema>) {
setIsLoading(true);
setFeedback(null);
if (!(await emailForm.trigger())) {
setIsLoading(false);
return;
}
const { data, error } = await supabase!.auth.resetPasswordForEmail(
d.email,
{
redirectTo: `${window.location.origin}/reset_password`,
},
);
if (error) {
setFeedback(error.message);
setIsLoading(false);
return;
}
setFeedback("Password reset email sent. Please check your email.");
setIsLoading(false);
}
async function onResetPassword(d: z.infer<typeof resetPasswordFormSchema>) {
setIsLoading(true);
setFeedback(null);
if (!(await resetPasswordForm.trigger())) {
setIsLoading(false);
return;
}
const { data, error } = await supabase!.auth.updateUser({
password: d.password,
});
if (error) {
setFeedback(error.message);
setIsLoading(false);
return;
}
await supabase!.auth.signOut();
router.push("/login");
}
return ( return (
<div className="flex h-full flex-col items-center justify-center"> <AuthCard>
<div className="w-full max-w-md"> <AuthHeader>Reset Password</AuthHeader>
<h1 className="text-center text-3xl font-bold">Reset Password</h1>
{user ? ( {user ? (
<form <form onSubmit={changePasswordForm.handleSubmit(onChangePassword)}>
onSubmit={resetPasswordForm.handleSubmit(onResetPassword)} <Form {...changePasswordForm}>
className="mt-6 space-y-6"
>
<Form {...resetPasswordForm}>
<FormField <FormField
control={resetPasswordForm.control} control={changePasswordForm.control}
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem className="mb-4"> <FormItem className="mb-6">
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<FormControl> <FormControl>
<Input <PasswordInput {...field} />
type="password"
placeholder="password"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={resetPasswordForm.control} control={changePasswordForm.control}
name="confirmPassword" name="confirmPassword"
render={({ field }) => ( render={({ field }) => (
<FormItem className="mb"> <FormItem className="mb-6">
<FormLabel>Confirm Password</FormLabel> <FormLabel>Confirm Password</FormLabel>
<FormControl> <FormControl>
<Input <PasswordInput {...field} />
type="password"
placeholder="password"
{...field}
/>
</FormControl> </FormControl>
<FormDescription className="text-sm font-normal leading-tight text-slate-500">
Password needs to be at least 6 characters long
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<Button <AuthButton
onClick={() => onChangePassword(changePasswordForm.getValues())}
isLoading={isLoading}
type="submit" type="submit"
className="w-full"
disabled={isLoading}
onClick={() => onResetPassword(resetPasswordForm.getValues())}
> >
{isLoading ? <FaSpinner className="mr-2 animate-spin" /> : null} Update password
Reset Password </AuthButton>
</Button> <AuthFeedback message={feedback} isError={isError} />
</Form> </Form>
</form> </form>
) : ( ) : (
<form <form onSubmit={sendEmailForm.handleSubmit(onSendEmail)}>
onSubmit={emailForm.handleSubmit(onSendEmail)} <Form {...sendEmailForm}>
className="mt-6 space-y-6"
>
<Form {...emailForm}>
<FormField <FormField
control={emailForm.control} control={sendEmailForm.control}
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem className="mb-4"> <FormItem className="mb-6">
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input placeholder="user@email.com" {...field} /> <Input placeholder="m@example.com" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<Button <AuthButton
onClick={() => onSendEmail(sendEmailForm.getValues())}
isLoading={isLoading}
disabled={disabled}
type="submit" type="submit"
className="w-full"
disabled={isLoading}
onClick={() => onSendEmail(emailForm.getValues())}
> >
{isLoading ? <FaSpinner className="mr-2 animate-spin" /> : null} Send reset email
Send Reset Email </AuthButton>
</Button> <AuthFeedback message={feedback} isError={isError} />
{feedback ? (
<div className="text-center text-sm text-red-500">
{feedback}
</div>
) : null}
</Form> </Form>
</form> </form>
)} )}
</div> </AuthCard>
</div>
); );
} }

View File

@ -0,0 +1,44 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { signupFormSchema } from "@/types/auth";
export async function signup(values: z.infer<typeof signupFormSchema>) {
"use server";
return await Sentry.withServerActionInstrumentation(
"signup",
{},
async () => {
const supabase = getServerSupabase();
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.error("Error signing up", error);
// FIXME: supabase doesn't return the correct error message for this case
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

@ -0,0 +1,219 @@
"use client";
import { signup } from "./actions";
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 { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Checkbox } from "@/components/ui/checkbox";
import useSupabase from "@/hooks/useSupabase";
import Spinner from "@/components/Spinner";
import {
AuthCard,
AuthHeader,
AuthButton,
AuthFeedback,
AuthBottomText,
PasswordInput,
} from "@/components/auth";
import { signupFormSchema } from "@/types/auth";
export default function SignupPage() {
const { supabase, user, isUserLoading } = useSupabase();
const [feedback, setFeedback] = useState<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [showWaitlistPrompt, setShowWaitlistPrompt] = useState(false);
const form = useForm<z.infer<typeof signupFormSchema>>({
resolver: zodResolver(signupFormSchema),
defaultValues: {
email: "",
password: "",
confirmPassword: "",
agreeToTerms: false,
},
});
const onSignup = useCallback(
async (data: z.infer<typeof signupFormSchema>) => {
setIsLoading(true);
if (!(await form.trigger())) {
setIsLoading(false);
return;
}
const error = await signup(data);
setIsLoading(false);
if (error) {
setShowWaitlistPrompt(true);
return;
}
setFeedback(null);
},
[form],
);
if (user) {
console.debug("User exists, redirecting to /");
router.push("/");
}
if (isUserLoading || user) {
return <Spinner />;
}
if (!supabase) {
return (
<div>
User accounts are disabled because Supabase client is unavailable
</div>
);
}
return (
<AuthCard>
<AuthHeader>Create a new account</AuthHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSignup)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="m@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormDescription className="text-sm font-normal leading-tight text-slate-500">
Password needs to be at least 6 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AuthButton
onClick={() => onSignup(form.getValues())}
isLoading={isLoading}
type="submit"
>
Sign up
</AuthButton>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="mt-6 flex flex-row items-start -space-y-1 space-x-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="">
<FormLabel>
<span className="mr-1 text-sm font-normal leading-normal text-slate-950">
I agree to the
</span>
<Link
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
Terms of Use
</Link>
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
and
</span>
<Link
href="https://www.notion.so/auto-gpt/Privacy-Policy-ab11c9c20dbd4de1a15dcffe84d77984"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
Privacy Policy
</Link>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
</form>
<AuthFeedback message={feedback} isError={true} />
</Form>
{showWaitlistPrompt && (
<div>
<span className="mr-1 text-sm font-normal leading-normal text-red-500">
The provided email may not be allowed to sign up.
</span>
<br />
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
- AutoGPT Platform is currently in closed beta. You can join
</span>
<Link
href="https://agpt.co/waitlist"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
the waitlist here.
</Link>
<br />
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
- Make sure you use the same email address you used to sign up for
the waitlist.
</span>
<br />
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
- You can self host the platform, visit our
</span>
<Link
href="https://agpt.co/waitlist"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
GitHub repository.
</Link>
</div>
)}
<AuthBottomText
text="Already a member?"
linkText="Log in"
href="/login"
/>
</AuthCard>
);
}

View File

@ -0,0 +1,217 @@
"use client";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { useCallback, useContext, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator";
import { useToast } from "@/components/ui/use-toast";
import { IconKey, IconUser } from "@/components/ui/icons";
import { LogOutIcon, Trash2Icon } from "lucide-react";
import { providerIcons } from "@/components/integrations/credentials-input";
import { CredentialsProvidersContext } from "@/components/integrations/credentials-provider";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CredentialsProviderName } from "@/lib/autogpt-server-api";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import useSupabase from "@/hooks/useSupabase";
import Spinner from "@/components/Spinner";
export default function PrivatePage() {
const { supabase, user, isUserLoading } = useSupabase();
const router = useRouter();
const providers = useContext(CredentialsProvidersContext);
const { toast } = useToast();
const [confirmationDialogState, setConfirmationDialogState] = useState<
| {
open: true;
message: string;
onConfirm: () => void;
onReject: () => void;
}
| { open: false }
>({ open: false });
const removeCredentials = useCallback(
async (
provider: CredentialsProviderName,
id: string,
force: boolean = false,
) => {
if (!providers || !providers[provider]) {
return;
}
let result;
try {
result = await providers[provider].deleteCredentials(id, force);
} catch (error: any) {
toast({
title: "Something went wrong when deleting credentials: " + error,
variant: "destructive",
duration: 2000,
});
setConfirmationDialogState({ open: false });
return;
}
if (result.deleted) {
if (result.revoked) {
toast({
title: "Credentials deleted",
duration: 2000,
});
} else {
toast({
title: "Credentials deleted from AutoGPT",
description: `You may also manually remove the connection to AutoGPT at ${provider}!`,
duration: 3000,
});
}
setConfirmationDialogState({ open: false });
} else if (result.need_confirmation) {
setConfirmationDialogState({
open: true,
message: result.message,
onConfirm: () => removeCredentials(provider, id, true),
onReject: () => setConfirmationDialogState({ open: false }),
});
}
},
[providers, toast],
);
//TODO: remove when the way system credentials are handled is updated
// This contains ids for built-in "Use Credits for X" credentials
const hiddenCredentials = useMemo(
() => [
"fdb7f412-f519-48d1-9b5f-d2f73d0e01fe", // Revid
"760f84fc-b270-42de-91f6-08efe1b512d0", // Ideogram
"6b9fc200-4726-4973-86c9-cd526f5ce5db", // Replicate
"53c25cb8-e3ee-465c-a4d1-e75a4c899c2a", // OpenAI
"24e5d942-d9e3-4798-8151-90143ee55629", // Anthropic
"4ec22295-8f97-4dd1-b42b-2c6957a02545", // Groq
"7f7b0654-c36b-4565-8fa7-9a52575dfae2", // D-ID
"7f26de70-ba0d-494e-ba76-238e65e7b45f", // Jina
"66f20754-1b81-48e4-91d0-f4f0dd82145f", // Unreal Speech
"b5a0e27d-0c98-4df3-a4b9-10193e1f3c40", // Open Router
],
[],
);
if (isUserLoading) {
return <Spinner />;
}
if (!user || !supabase) {
router.push("/login");
return null;
}
const allCredentials = providers
? Object.values(providers).flatMap((provider) =>
[...provider.savedOAuthCredentials, ...provider.savedApiKeys]
.filter((cred) => !hiddenCredentials.includes(cred.id))
.map((credentials) => ({
...credentials,
provider: provider.provider,
providerName: provider.providerName,
ProviderIcon: providerIcons[provider.provider],
TypeIcon: { oauth2: IconUser, api_key: IconKey }[credentials.type],
})),
)
: [];
return (
<div className="mx-auto max-w-3xl md:py-8">
<h2 className="mb-4 text-lg">Connections & Credentials</h2>
<Table>
<TableHeader>
<TableRow>
<TableHead>Provider</TableHead>
<TableHead>Name</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allCredentials.map((cred) => (
<TableRow key={cred.id}>
<TableCell>
<div className="flex items-center space-x-1.5">
<cred.ProviderIcon className="h-4 w-4" />
<strong>{cred.providerName}</strong>
</div>
</TableCell>
<TableCell>
<div className="flex h-full items-center space-x-1.5">
<cred.TypeIcon />
<span>{cred.title || cred.username}</span>
</div>
<small className="text-muted-foreground">
{
{
oauth2: "OAuth2 credentials",
api_key: "API key",
}[cred.type]
}{" "}
- <code>{cred.id}</code>
</small>
</TableCell>
<TableCell className="w-0 whitespace-nowrap">
<Button
variant="destructive"
onClick={() => removeCredentials(cred.provider, cred.id)}
>
<Trash2Icon className="mr-1.5 size-4" /> Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<AlertDialog open={confirmationDialogState.open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
{confirmationDialogState.open && confirmationDialogState.message}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() =>
confirmationDialogState.open &&
confirmationDialogState.onReject()
}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() =>
confirmationDialogState.open &&
confirmationDialogState.onConfirm()
}
>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -35,22 +35,27 @@ export default async function Page({
}: { }: {
params: { creator: string; slug: string }; params: { creator: string; slug: string };
}) { }) {
const creator_lower = params.creator.toLowerCase();
const api = new BackendAPI(); const api = new BackendAPI();
const agent = await api.getStoreAgent(params.creator, params.slug); const agent = await api.getStoreAgent(creator_lower, params.slug);
const otherAgents = await api.getStoreAgents({ creator: params.creator }); const otherAgents = await api.getStoreAgents({ creator: creator_lower });
const similarAgents = await api.getStoreAgents({ const similarAgents = await api.getStoreAgents({
search_query: agent.categories[0], // We are using slug as we know its has been sanitized and is not null
search_query: agent.slug.replace(/-/g, " "),
}); });
const breadcrumbs = [ const breadcrumbs = [
{ name: "Store", link: "/store" }, { name: "Store", link: "/store" },
{ name: agent.creator, link: `/store/creator/${agent.creator}` }, {
name: agent.creator,
link: `/store/creator/${encodeURIComponent(agent.creator)}`,
},
{ name: agent.agent_name, link: "#" }, { name: agent.agent_name, link: "#" },
]; ];
return ( return (
<div className="mx-auto w-screen max-w-[1360px]"> <div className="mx-auto w-screen max-w-[1360px]">
<main className="px-4 md:mt-4 lg:mt-8"> <main className="mt-5 px-4">
<BreadCrumbs items={breadcrumbs} /> <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="mt-4 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 md:mt-8 md:flex-row md:gap-8">
@ -68,14 +73,20 @@ export default async function Page({
storeListingVersionId={agent.store_listing_version_id} storeListingVersionId={agent.store_listing_version_id}
/> />
</div> </div>
<AgentImages images={agent.agent_image} /> <AgentImages
images={
agent.agent_video
? [agent.agent_video, ...agent.agent_image]
: agent.agent_image
}
/>
</div> </div>
<Separator className="my-6" /> <Separator className="mb-[25px] mt-6" />
<AgentsSection <AgentsSection
agents={otherAgents.agents} agents={otherAgents.agents}
sectionTitle={`Other agents by ${agent.creator}`} sectionTitle={`Other agents by ${agent.creator}`}
/> />
<Separator className="my-6" /> <Separator className="mb-[25px] mt-6" />
<AgentsSection <AgentsSection
agents={similarAgents.agents} agents={similarAgents.agents}
sectionTitle="Similar agents" sectionTitle="Similar agents"

View File

@ -15,7 +15,7 @@ export async function generateMetadata({
params: { creator: string }; params: { creator: string };
}): Promise<Metadata> { }): Promise<Metadata> {
const api = new BackendAPI(); const api = new BackendAPI();
const creator = await api.getStoreCreator(params.creator); const creator = await api.getStoreCreator(params.creator.toLowerCase());
return { return {
title: `${creator.name} - AutoGPT Store`, title: `${creator.name} - AutoGPT Store`,
@ -44,7 +44,7 @@ export default async function Page({
return ( return (
<div className="mx-auto w-screen max-w-[1360px]"> <div className="mx-auto w-screen max-w-[1360px]">
<main className="px-4 md:mt-4 lg:mt-8"> <main className="mt-5 px-4">
<BreadCrumbs <BreadCrumbs
items={[ items={[
{ name: "Store", link: "/store" }, { name: "Store", link: "/store" },
@ -64,9 +64,16 @@ export default async function Page({
/> />
</div> </div>
<div className="flex min-w-0 flex-1 flex-col gap-4 sm:gap-6 md:gap-8"> <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]"> <p className="font-geist text-underline-position-from-font text-decoration-skip-none text-left text-base font-medium leading-6">
About
</p>
<div
className="font-poppins text-[48px] font-normal leading-[59px] text-neutral-900 dark:text-zinc-50"
style={{ whiteSpace: "pre-line" }}
>
{creator.description} {creator.description}
</div> </div>
<CreatorLinks links={creator.links} /> <CreatorLinks links={creator.links} />
</div> </div>
</div> </div>

View File

@ -107,7 +107,7 @@ async function getStoreData() {
// FIX: Correct metadata // FIX: Correct metadata
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Agent Store - NextGen AutoGPT", title: "Marketplace - NextGen AutoGPT",
description: "Find and use AI Agents created by our community", description: "Find and use AI Agents created by our community",
applicationName: "NextGen AutoGPT Store", applicationName: "NextGen AutoGPT Store",
authors: [{ name: "AutoGPT Team" }], authors: [{ name: "AutoGPT Team" }],
@ -123,7 +123,7 @@ export const metadata: Metadata = {
follow: true, follow: true,
}, },
openGraph: { openGraph: {
title: "Agent Store - NextGen AutoGPT", title: "Marketplace - NextGen AutoGPT",
description: "Find and use AI Agents created by our community", description: "Find and use AI Agents created by our community",
type: "website", type: "website",
siteName: "NextGen AutoGPT Store", siteName: "NextGen AutoGPT Store",
@ -138,7 +138,7 @@ export const metadata: Metadata = {
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "Agent Store - NextGen AutoGPT", title: "Marketplace - NextGen AutoGPT",
description: "Find and use AI Agents created by our community", description: "Find and use AI Agents created by our community",
images: ["/images/store-twitter.png"], images: ["/images/store-twitter.png"],
}, },

View File

@ -6,7 +6,7 @@ import React, {
useContext, useContext,
useMemo, useMemo,
} from "react"; } from "react";
import { NodeProps, useReactFlow, Node, Edge } from "@xyflow/react"; import { NodeProps, useReactFlow, Node as XYNode, Edge } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import "./customnode.css"; import "./customnode.css";
import InputModalComponent from "./InputModalComponent"; import InputModalComponent from "./InputModalComponent";
@ -16,6 +16,7 @@ import {
BlockIOSubSchema, BlockIOSubSchema,
BlockIOStringSubSchema, BlockIOStringSubSchema,
Category, Category,
Node,
NodeExecutionResult, NodeExecutionResult,
BlockUIType, BlockUIType,
BlockCost, BlockCost,
@ -71,7 +72,7 @@ export type CustomNodeData = {
outputSchema: BlockIORootSchema; outputSchema: BlockIORootSchema;
hardcodedValues: { [key: string]: any }; hardcodedValues: { [key: string]: any };
connections: ConnectionData; connections: ConnectionData;
webhookId?: string; webhook?: Node["webhook"];
isOutputOpen: boolean; isOutputOpen: boolean;
status?: NodeExecutionResult["status"]; status?: NodeExecutionResult["status"];
/** executionResults contains outputs across multiple executions /** executionResults contains outputs across multiple executions
@ -87,7 +88,7 @@ export type CustomNodeData = {
uiType: BlockUIType; uiType: BlockUIType;
}; };
export type CustomNode = Node<CustomNodeData, "custom">; export type CustomNode = XYNode<CustomNodeData, "custom">;
export function CustomNode({ export function CustomNode({
data, data,
@ -237,7 +238,11 @@ export function CustomNode({
const isHidden = propSchema.hidden; const isHidden = propSchema.hidden;
const isConnectable = const isConnectable =
// No input connection handles on INPUT and WEBHOOK blocks // No input connection handles on INPUT and WEBHOOK blocks
![BlockUIType.INPUT, BlockUIType.WEBHOOK].includes(nodeType) && ![
BlockUIType.INPUT,
BlockUIType.WEBHOOK,
BlockUIType.WEBHOOK_MANUAL,
].includes(nodeType) &&
// No input connection handles for credentials // No input connection handles for credentials
propKey !== "credentials" && propKey !== "credentials" &&
// For OUTPUT blocks, only show the 'value' (hides 'name') input connection handle // For OUTPUT blocks, only show the 'value' (hides 'name') input connection handle
@ -549,22 +554,25 @@ export function CustomNode({
>(null); >(null);
useEffect(() => { useEffect(() => {
if (data.uiType != BlockUIType.WEBHOOK) return; if (
if (!data.webhookId) { ![BlockUIType.WEBHOOK, BlockUIType.WEBHOOK_MANUAL].includes(data.uiType)
)
return;
if (!data.webhook) {
setWebhookStatus("none"); setWebhookStatus("none");
return; return;
} }
setWebhookStatus("pending"); setWebhookStatus("pending");
api api
.pingWebhook(data.webhookId) .pingWebhook(data.webhook.id)
.then((pinged) => setWebhookStatus(pinged ? "works" : "exists")) .then((pinged) => setWebhookStatus(pinged ? "works" : "exists"))
.catch((error: Error) => .catch((error: Error) =>
error.message.includes("ping timed out") error.message.includes("ping timed out")
? setWebhookStatus("broken") ? setWebhookStatus("broken")
: setWebhookStatus("none"), : setWebhookStatus("none"),
); );
}, [data.uiType, data.webhookId, api, setWebhookStatus]); }, [data.uiType, data.webhook, api, setWebhookStatus]);
const webhookStatusDot = useMemo( const webhookStatusDot = useMemo(
() => () =>
@ -718,14 +726,38 @@ export function CustomNode({
</div> </div>
{/* Body */} {/* Body */}
<div className="ml-5 mt-6 rounded-b-xl"> <div className="mx-5 my-6 rounded-b-xl">
{/* Input Handles */} {/* Input Handles */}
{data.uiType !== BlockUIType.NOTE ? ( {data.uiType !== BlockUIType.NOTE ? (
<div <div data-id="input-handles">
className="flex w-fit items-start justify-between"
data-id="input-handles"
>
<div> <div>
{data.uiType === BlockUIType.WEBHOOK_MANUAL &&
(data.webhook ? (
<div className="nodrag mr-5 flex flex-col gap-1">
Webhook URL:
<div className="flex gap-2 rounded-md bg-gray-50 p-2">
<code className="select-all text-sm">
{data.webhook.url}
</code>
<Button
variant="outline"
size="icon"
className="size-7 flex-none"
onClick={() =>
data.webhook &&
navigator.clipboard.writeText(data.webhook.url)
}
title="Copy webhook URL"
>
<CopyIcon className="size-4" />
</Button>
</div>
</div>
) : (
<p className="italic text-gray-500">
(A Webhook URL will be generated when you save the agent)
</p>
))}
{data.inputSchema && {data.inputSchema &&
generateInputHandles(data.inputSchema, data.uiType)} generateInputHandles(data.inputSchema, data.uiType)}
</div> </div>
@ -746,7 +778,6 @@ export function CustomNode({
<Switch <Switch
onCheckedChange={toggleAdvancedSettings} onCheckedChange={toggleAdvancedSettings}
checked={isAdvancedOpen} checked={isAdvancedOpen}
className="mr-5"
/> />
</div> </div>
</> </>
@ -755,7 +786,7 @@ export function CustomNode({
{data.uiType !== BlockUIType.NOTE && ( {data.uiType !== BlockUIType.NOTE && (
<> <>
<LineSeparator /> <LineSeparator />
<div className="flex items-start justify-end rounded-b-xl pb-2 pr-2 pt-6"> <div className="flex items-start justify-end rounded-b-xl pt-6">
<div className="flex-none"> <div className="flex-none">
{data.outputSchema && {data.outputSchema &&
generateOutputHandles(data.outputSchema, data.uiType)} generateOutputHandles(data.outputSchema, data.uiType)}

View File

@ -98,7 +98,9 @@ const FlowEditor: React.FC<{
requestSaveAndRun, requestSaveAndRun,
requestStopRun, requestStopRun,
scheduleRunner, scheduleRunner,
isSaving,
isRunning, isRunning,
isStopping,
isScheduling, isScheduling,
setIsScheduling, setIsScheduling,
nodes, nodes,
@ -679,7 +681,8 @@ const FlowEditor: React.FC<{
botChildren={ botChildren={
<SaveControl <SaveControl
agentMeta={savedAgent} agentMeta={savedAgent}
onSave={(isTemplate) => requestSave(isTemplate ?? false)} canSave={!isSaving && !isRunning && !isStopping}
onSave={() => requestSave()}
agentDescription={agentDescription} agentDescription={agentDescription}
onDescriptionChange={setAgentDescription} onDescriptionChange={setAgentDescription}
agentName={agentName} agentName={agentName}

View File

@ -82,7 +82,7 @@ const NodeHandle: FC<HandleProps> = ({
data-testid={`output-handle-${keyName}`} data-testid={`output-handle-${keyName}`}
position={Position.Right} position={Position.Right}
id={keyName} id={keyName}
className="group -mr-[26px]" className="group -mr-[38px]"
> >
<div className="pointer-events-none flex items-center"> <div className="pointer-events-none flex items-center">
{label} {label}

View File

@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Graph, GraphCreatable } from "@/lib/autogpt-server-api"; import { Graph, GraphCreatable } from "@/lib/autogpt-server-api";
import { cn } from "@/lib/utils"; import { cn, removeCredentials } from "@/lib/utils";
import { EnterIcon } from "@radix-ui/react-icons"; import { EnterIcon } from "@radix-ui/react-icons";
import { useBackendAPI } from "@/lib/autogpt-server-api/context"; import { useBackendAPI } from "@/lib/autogpt-server-api/context";
@ -150,6 +150,7 @@ export const AgentImportForm: React.FC<
); );
} }
const agent = obj as Graph; const agent = obj as Graph;
removeCredentials(agent);
updateBlockIDs(agent); updateBlockIDs(agent);
setAgentObject(agent); setAgentObject(agent);
form.setValue("agentName", agent.name); form.setValue("agentName", agent.name);

View File

@ -47,7 +47,7 @@ export const AgentImageItem: React.FC<AgentImageItemProps> = React.memo(
return ( return (
<div className="relative"> <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]"> <div className="h-[15rem] overflow-hidden rounded-[26px] bg-[#a8a8a8] dark:bg-neutral-700 sm:h-[20rem] sm:w-full md:h-[25rem] lg:h-[30rem]">
{isValidVideoUrl(image) ? ( {isValidVideoUrl(image) ? (
getYouTubeVideoId(image) ? ( getYouTubeVideoId(image) ? (
<iframe <iframe

View File

@ -26,7 +26,7 @@ export const AgentImages: React.FC<AgentImagesProps> = ({ images }) => {
); );
return ( return (
<div className="w-full overflow-y-auto bg-white px-2 dark:bg-gray-800 lg:w-[56.25rem]"> <div className="w-full overflow-y-auto bg-white px-2 dark:bg-transparent lg:w-[56.25rem]">
<div className="space-y-4 sm:space-y-6 md:space-y-[1.875rem]"> <div className="space-y-4 sm:space-y-6 md:space-y-[1.875rem]">
{images.map((image, index) => ( {images.map((image, index) => (
<AgentImageItem <AgentImageItem

View File

@ -5,6 +5,7 @@ import { IconPlay, StarRatingIcons } from "@/components/ui/icons";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import BackendAPI from "@/lib/autogpt-server-api"; import BackendAPI from "@/lib/autogpt-server-api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
interface AgentInfoProps { interface AgentInfoProps {
name: string; name: string;
creator: string; creator: string;
@ -56,9 +57,12 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
<div className="font-geist text-base font-normal text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl"> <div className="font-geist text-base font-normal text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl">
by by
</div> </div>
<div className="font-geist text-base font-medium text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl"> <Link
href={`/store/creator/${encodeURIComponent(creator)}`}
className="font-geist text-base font-medium text-neutral-800 hover:underline dark:text-neutral-200 sm:text-lg lg:text-xl"
>
{creator} {creator}
</div> </Link>
</div> </div>
{/* Short Description */} {/* Short Description */}
@ -67,7 +71,7 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
</div> </div>
{/* Run Agent Button */} {/* Run Agent Button */}
<div className="mb-4 w-full lg:mb-6"> <div className="mb-4 w-full lg:mb-[60px]">
<button <button
onClick={handleAddToLibrary} onClick={handleAddToLibrary}
className="inline-flex w-full items-center justify-center gap-2 rounded-[38px] bg-violet-600 px-4 py-3 transition-colors hover:bg-violet-700 sm:w-auto sm:gap-2.5 sm:px-5 sm:py-3.5 lg:px-6 lg:py-4" 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"
@ -80,7 +84,7 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
</div> </div>
{/* Rating and Runs */} {/* Rating and Runs */}
<div className="mb-4 flex w-full items-center justify-between lg:mb-6"> <div className="mb-4 flex w-full items-center justify-between lg:mb-[44px]">
<div className="flex items-center gap-1.5 sm:gap-2"> <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"> <span className="font-geist whitespace-nowrap text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
{rating.toFixed(1)} {rating.toFixed(1)}
@ -93,28 +97,28 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
</div> </div>
{/* Separator */} {/* Separator */}
<Separator className="mb-4 lg:mb-6" /> <Separator className="mb-4 lg:mb-[44px]" />
{/* Description Section */} {/* Description Section */}
<div className="mb-4 w-full lg:mb-6"> <div className="mb-4 w-full lg:mb-[36px]">
<div className="mb-1.5 text-xs font-medium text-neutral-800 dark:text-neutral-200 sm:mb-2 sm:text-sm"> <div className="font-geist decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
Description Description
</div> </div>
<div className="font-geist w-full whitespace-pre-line text-sm font-normal text-neutral-600 dark:text-neutral-300 sm:text-base"> <div className="font-geist decoration-skip-ink-none text-base font-normal leading-6 text-neutral-600 underline-offset-[from-font] dark:text-neutral-400">
{longDescription} {longDescription}
</div> </div>
</div> </div>
{/* Categories */} {/* Categories */}
<div className="mb-4 flex w-full flex-col gap-1.5 sm:gap-2 lg:mb-6"> <div className="mb-4 flex w-full flex-col gap-1.5 sm:gap-2 lg:mb-[36px]">
<div className="text-xs font-medium text-neutral-800 dark:text-neutral-200 sm:text-sm"> <div className="font-geist decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
Categories Categories
</div> </div>
<div className="flex flex-wrap gap-1.5 sm:gap-2"> <div className="flex flex-wrap gap-1.5 sm:gap-2">
{categories.map((category, index) => ( {categories.map((category, index) => (
<div <div
key={index} 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" className="font-geist decoration-skip-ink-none whitespace-nowrap rounded-full border border-neutral-600 bg-white px-2 py-0.5 text-base font-normal leading-6 text-neutral-800 underline-offset-[from-font] dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 sm:px-[16px] sm:py-[10px]"
> >
{category} {category}
</div> </div>
@ -124,10 +128,10 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
{/* Version History */} {/* Version History */}
<div className="flex w-full flex-col gap-0.5 sm:gap-1"> <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"> <div className="font-geist decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
Version history Version history
</div> </div>
<div className="text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm"> <div className="font-geist decoration-skip-ink-none text-base font-normal leading-6 text-neutral-600 underline-offset-[from-font] dark:text-neutral-400">
Last updated {lastUpdated} Last updated {lastUpdated}
</div> </div>
<div className="text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm"> <div className="text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm">

View File

@ -4,7 +4,7 @@ import * as React from "react";
import Image from "next/image"; import Image from "next/image";
import { IconStarFilled, IconMore, IconEdit } from "@/components/ui/icons"; import { IconStarFilled, IconMore, IconEdit } from "@/components/ui/icons";
import { Status, StatusType } from "./Status"; import { Status, StatusType } from "./Status";
import * as ContextMenu from "@radix-ui/react-context-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { TrashIcon } from "@radix-ui/react-icons"; import { TrashIcon } from "@radix-ui/react-icons";
import { StoreSubmissionRequest } from "@/lib/autogpt-server-api/types"; import { StoreSubmissionRequest } from "@/lib/autogpt-server-api/types";
@ -139,30 +139,30 @@ export const AgentTableRow: React.FC<AgentTableRowProps> = ({
{/* Actions - Three dots menu */} {/* Actions - Three dots menu */}
<div className="flex justify-end"> <div className="flex justify-end">
<ContextMenu.Root> <DropdownMenu.Root>
<ContextMenu.Trigger> <DropdownMenu.Trigger>
<button className="rounded-full p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700"> <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" /> <IconMore className="h-5 w-5 text-neutral-800 dark:text-neutral-200" />
</button> </button>
</ContextMenu.Trigger> </DropdownMenu.Trigger>
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800"> <DropdownMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800">
<ContextMenu.Item <DropdownMenu.Item
onSelect={handleEdit} onSelect={handleEdit}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700" 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" /> <IconEdit className="mr-2 h-5 w-5 dark:text-gray-100" />
<span className="dark:text-gray-100">Edit</span> <span className="dark:text-gray-100">Edit</span>
</ContextMenu.Item> </DropdownMenu.Item>
<ContextMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" /> <DropdownMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" />
<ContextMenu.Item <DropdownMenu.Item
onSelect={handleDelete} 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" 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" /> <TrashIcon className="mr-2 h-5 w-5 text-red-500 dark:text-red-400" />
<span className="dark:text-red-400">Delete</span> <span className="dark:text-red-400">Delete</span>
</ContextMenu.Item> </DropdownMenu.Item>
</ContextMenu.Content> </DropdownMenu.Content>
</ContextMenu.Root> </DropdownMenu.Root>
</div> </div>
</div> </div>
</div> </div>

View File

@ -26,13 +26,13 @@ export const BecomeACreator: React.FC<BecomeACreatorProps> = ({
<div className="left-0 top-0 h-px w-full bg-gray-200 dark:bg-gray-700" /> <div className="left-0 top-0 h-px w-full bg-gray-200 dark:bg-gray-700" />
{/* Title */} {/* Title */}
<h2 className="mb-8 mt-6 text-2xl leading-7 text-neutral-800 dark:text-neutral-200"> <h2 className="font-poppins underline-from-font decoration-skip-ink-none mb-[77px] mt-[25px] text-left text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
{title} {title}
</h2> </h2>
{/* Content Container */} {/* 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"> <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 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]"> <h2 className="font-poppins underline-from-font decoration-skip-ink-none mb-6 text-center text-[48px] font-semibold leading-[54px] tracking-[-0.012em] text-neutral-950 dark:text-neutral-50 md:mb-8 lg:mb-12">
Build AI agents and share Build AI agents and share
<br /> <br />
<span className="text-violet-600 dark:text-violet-400"> <span className="text-violet-600 dark:text-violet-400">

View File

@ -22,7 +22,7 @@ export const BreadCrumbs: React.FC<BreadCrumbsProps> = ({ items }) => {
<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"> <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" /> <IconRightArrow className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
</button> */} </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"> <div className="flex h-auto flex-wrap items-center justify-start gap-4 rounded-[5rem] bg-white dark:bg-transparent">
{items.map((item, index) => ( {items.map((item, index) => (
<React.Fragment key={index}> <React.Fragment key={index}>
<Link href={item.link}> <Link href={item.link}>

View File

@ -33,10 +33,10 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex w-full flex-col items-start justify-start gap-1.5"> <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"> <div className="font-poppins w-full text-[35px] font-medium leading-10 text-neutral-900 dark:text-neutral-100 sm:text-[35px] sm:leading-10">
{username} {username}
</div> </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"> <div className="font-geist w-full text-lg font-normal leading-6 text-neutral-800 dark:text-neutral-200 sm:text-xl sm:leading-7">
@{handle} @{handle}
</div> </div>
</div> </div>
@ -57,7 +57,7 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
{categories.map((category, index) => ( {categories.map((category, index) => (
<div <div
key={index} 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" className="flex items-center justify-center gap-2.5 rounded-[34px] border border-neutral-600 px-4 py-3 dark:border-neutral-400"
role="listitem" role="listitem"
> >
<div className="font-neue text-base font-normal leading-normal text-neutral-800 dark:text-neutral-200"> <div className="font-neue text-base font-normal leading-normal text-neutral-800 dark:text-neutral-200">
@ -77,7 +77,7 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
Average rating Average rating
</div> </div>
<div className="inline-flex items-center gap-2"> <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"> <div className="font-geist text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
{averageRating.toFixed(1)} {averageRating.toFixed(1)}
</div> </div>
<div <div
@ -93,7 +93,7 @@ export const CreatorInfoCard: React.FC<CreatorInfoCardProps> = ({
<div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200"> <div className="w-full font-neue text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Number of runs Number of runs
</div> </div>
<div className="font-neue text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200"> <div className="font-geist text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
{new Intl.NumberFormat().format(totalRuns)} runs {new Intl.NumberFormat().format(totalRuns)} runs
</div> </div>
</div> </div>

View File

@ -8,7 +8,7 @@ interface FilterChipsProps {
onFilterChange?: (selectedFilters: string[]) => void; onFilterChange?: (selectedFilters: string[]) => void;
multiSelect?: boolean; multiSelect?: boolean;
} }
/** FilterChips is a component that allows the user to select filters from a list of badges. It is used on the Agent Store home page */ /** FilterChips is a component that allows the user to select filters from a list of badges. It is used on the Marketplace home page */
export const FilterChips: React.FC<FilterChipsProps> = ({ export const FilterChips: React.FC<FilterChipsProps> = ({
badges, badges,
onFilterChange, onFilterChange,

View File

@ -6,11 +6,13 @@ import { MobileNavBar } from "./MobileNavBar";
import { Button } from "./Button"; import { Button } from "./Button";
import CreditsCard from "./CreditsCard"; import CreditsCard from "./CreditsCard";
import { ProfileDetails } from "@/lib/autogpt-server-api/types"; import { ProfileDetails } from "@/lib/autogpt-server-api/types";
import { ThemeToggle } from "./ThemeToggle";
import { NavbarLink } from "./NavbarLink"; import { NavbarLink } from "./NavbarLink";
import getServerUser from "@/lib/supabase/getServerUser"; import getServerUser from "@/lib/supabase/getServerUser";
import BackendAPI from "@/lib/autogpt-server-api"; import BackendAPI from "@/lib/autogpt-server-api";
// Disable theme toggle for now
// import { ThemeToggle } from "./ThemeToggle";
interface NavLink { interface NavLink {
name: string; name: string;
href: string; href: string;
@ -55,7 +57,7 @@ export const Navbar = async ({ links, menuItemGroups }: NavbarProps) => {
return ( return (
<> <>
<nav className="sticky top-0 z-50 hidden h-16 w-[1408px] items-center justify-between rounded-bl-2xl rounded-br-2xl border border-white/50 bg-white/5 py-3 pl-6 pr-3 backdrop-blur-[26px] dark:border-gray-700 dark:bg-gray-900 md:inline-flex"> <nav className="sticky top-0 z-50 mx-[16px] hidden h-16 w-full max-w-[1600px] items-center justify-between rounded-bl-2xl rounded-br-2xl border border-white/50 bg-white/5 py-3 pl-6 pr-3 backdrop-blur-[26px] dark:border-gray-700 dark:bg-gray-900 md:inline-flex">
<div className="flex items-center gap-11"> <div className="flex items-center gap-11">
<div className="relative h-10 w-[88.87px]"> <div className="relative h-10 w-[88.87px]">
<IconAutoGPTLogo className="h-full w-full" /> <IconAutoGPTLogo className="h-full w-full" />
@ -93,7 +95,7 @@ export const Navbar = async ({ links, menuItemGroups }: NavbarProps) => {
</Button> </Button>
</Link> </Link>
)} )}
<ThemeToggle /> {/* <ThemeToggle /> */}
</div> </div>
</nav> </nav>
{/* Mobile Navbar - Adjust positioning */} {/* Mobile Navbar - Adjust positioning */}
@ -107,7 +109,7 @@ export const Navbar = async ({ links, menuItemGroups }: NavbarProps) => {
groupName: "Navigation", groupName: "Navigation",
items: links.map((link) => ({ items: links.map((link) => ({
icon: icon:
link.name === "Agent Store" link.name === "Marketplace"
? IconType.Marketplace ? IconType.Marketplace
: link.name === "Library" : link.name === "Library"
? IconType.Library ? IconType.Library

View File

@ -20,6 +20,7 @@ export const NavbarLink = ({ name, href }: NavbarLinkProps) => {
const activeLink = "/" + (parts.length > 2 ? parts[2] : parts[1]); const activeLink = "/" + (parts.length > 2 ? parts[2] : parts[1]);
return ( return (
<Link href={href} data-testid={`navbar-link-${name.toLowerCase()}`}>
<div <div
className={`h-[48px] px-5 py-4 ${ className={`h-[48px] px-5 py-4 ${
activeLink === href activeLink === href
@ -47,7 +48,6 @@ export const NavbarLink = ({ name, href }: NavbarLinkProps) => {
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`} className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/> />
)} )}
<Link href={href}>
<div <div
className={`font-poppins text-[20px] font-medium leading-[28px] ${ className={`font-poppins text-[20px] font-medium leading-[28px] ${
activeLink === href activeLink === href
@ -57,7 +57,7 @@ export const NavbarLink = ({ name, href }: NavbarLinkProps) => {
> >
{name} {name}
</div> </div>
</Link>
</div> </div>
</Link>
); );
}; };

View File

@ -26,7 +26,7 @@ export const ProfileInfoForm = ({ profile }: { profile: CreatorDetails }) => {
name: profileData.name, name: profileData.name,
username: profileData.username, username: profileData.username,
description: profileData.description, description: profileData.description,
links: profileData.links, links: profileData.links.filter((link) => link), // Filter out empty links
avatar_url: profileData.avatar_url, avatar_url: profileData.avatar_url,
}; };
@ -225,11 +225,11 @@ export const ProfileInfoForm = ({ profile }: { profile: CreatorDetails }) => {
defaultValue={link || ""} defaultValue={link || ""}
className="font-circular w-full border-none bg-transparent text-base font-normal text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:text-white dark:placeholder:text-neutral-500" className="font-circular w-full border-none bg-transparent text-base font-normal text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:text-white dark:placeholder:text-neutral-500"
onChange={(e) => { onChange={(e) => {
const newLinks = [...profileData.links];
newLinks[linkNum - 1] = e.target.value;
const newProfileData = { const newProfileData = {
...profileData, ...profileData,
links: profileData.links.map((link, index) => links: newLinks,
index === linkNum - 1 ? e.target.value : link,
),
}; };
setProfileData(newProfileData); setProfileData(newProfileData);
}} }}

View File

@ -107,9 +107,9 @@ export const PublishAgentSelect: React.FC<PublishAgentSelectProps> = ({
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => ( {agents.map((agent) => (
<div <div
key={agent.name} key={agent.id}
className={`cursor-pointer overflow-hidden rounded-2xl transition-all ${ className={`cursor-pointer overflow-hidden rounded-2xl transition-all ${
selectedAgent === agent.name selectedAgentId === agent.id
? "shadow-lg ring-4 ring-violet-600" ? "shadow-lg ring-4 ring-violet-600"
: "hover:shadow-md" : "hover:shadow-md"
}`} }`}
@ -124,7 +124,7 @@ export const PublishAgentSelect: React.FC<PublishAgentSelectProps> = ({
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
aria-pressed={selectedAgent === agent.name} aria-pressed={selectedAgentId === agent.id}
> >
<div className="relative h-32 bg-gray-100 dark:bg-gray-700 sm:h-40"> <div className="relative h-32 bg-gray-100 dark:bg-gray-700 sm:h-40">
<Image <Image

View File

@ -19,6 +19,7 @@ interface PublishAgentInfoProps {
) => void; ) => void;
onClose: () => void; onClose: () => void;
initialData?: { initialData?: {
agent_id: string;
title: string; title: string;
subheader: string; subheader: string;
slug: string; slug: string;
@ -36,6 +37,7 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
onClose, onClose,
initialData, initialData,
}) => { }) => {
const [agentId, setAgentId] = React.useState<string | null>(null);
const [images, setImages] = React.useState<string[]>( const [images, setImages] = React.useState<string[]>(
initialData?.additionalImages initialData?.additionalImages
? [initialData.thumbnailSrc, ...initialData.additionalImages] ? [initialData.thumbnailSrc, ...initialData.additionalImages]
@ -59,11 +61,32 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
); );
const [slug, setSlug] = React.useState(initialData?.slug || ""); const [slug, setSlug] = React.useState(initialData?.slug || "");
const thumbnailsContainerRef = React.useRef<HTMLDivElement | null>(null); const thumbnailsContainerRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (initialData) {
setAgentId(initialData.agent_id);
setImagesWithValidation(initialData.additionalImages || []);
setSelectedImage(initialData.thumbnailSrc || null);
setTitle(initialData.title);
setSubheader(initialData.subheader);
setYoutubeLink(initialData.youtubeLink);
setCategory(initialData.category);
setDescription(initialData.description);
setSlug(initialData.slug);
}
}, [initialData]);
const setImagesWithValidation = (newImages: string[]) => {
// Remove duplicates
const uniqueImages = Array.from(new Set(newImages));
// Keep only first 5 images
const limitedImages = uniqueImages.slice(0, 5);
setImages(limitedImages);
};
const handleRemoveImage = (indexToRemove: number) => { const handleRemoveImage = (indexToRemove: number) => {
const newImages = [...images]; const newImages = [...images];
newImages.splice(indexToRemove, 1); newImages.splice(indexToRemove, 1);
setImages(newImages); setImagesWithValidation(newImages);
if (newImages[indexToRemove] === selectedImage) { if (newImages[indexToRemove] === selectedImage) {
setSelectedImage(newImages[0] || null); setSelectedImage(newImages[0] || null);
} }
@ -75,6 +98,8 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
}; };
const handleAddImage = async () => { const handleAddImage = async () => {
if (images.length >= 5) return;
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = "image/*"; input.accept = "image/*";
@ -102,11 +127,7 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
"$1", "$1",
); );
setImages((prev) => { setImagesWithValidation([...images, imageUrl]);
const newImages = [...prev, imageUrl];
console.log("Added image. Images now:", newImages);
return newImages;
});
if (!selectedImage) { if (!selectedImage) {
setSelectedImage(imageUrl); setSelectedImage(imageUrl);
} }
@ -115,6 +136,27 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
} }
}; };
const [isGenerating, setIsGenerating] = React.useState(false);
const handleGenerateImage = async () => {
if (isGenerating || images.length >= 5) return;
setIsGenerating(true);
try {
const api = new BackendAPI();
if (!agentId) {
throw new Error("Agent ID is required");
}
const { image_url } = await api.generateStoreSubmissionImage(agentId);
console.log("image_url", image_url);
setImagesWithValidation([...images, image_url]);
} catch (error) {
console.error("Failed to generate image:", error);
} finally {
setIsGenerating(false);
}
};
const handleSubmit = (e: React.MouseEvent<HTMLButtonElement>) => { const handleSubmit = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
onSubmit(title, subheader, slug, description, images, youtubeLink, [ onSubmit(title, subheader, slug, description, images, youtubeLink, [
@ -271,6 +313,7 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
</button> </button>
</div> </div>
))} ))}
{images.length < 5 && (
<Button <Button
onClick={handleAddImage} onClick={handleAddImage}
variant="ghost" variant="ghost"
@ -284,6 +327,7 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
Add image Add image
</span> </span>
</Button> </Button>
)}
</> </>
)} )}
</div> </div>
@ -300,9 +344,17 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
className="bg-neutral-800 text-white hover:bg-neutral-900 dark:bg-neutral-600 dark:hover:bg-neutral-500" className={`bg-neutral-800 text-white hover:bg-neutral-900 dark:bg-neutral-600 dark:hover:bg-neutral-500 ${
images.length >= 5 ? "cursor-not-allowed opacity-50" : ""
}`}
onClick={handleGenerateImage}
disabled={isGenerating || images.length >= 5}
> >
Generate {isGenerating
? "Generating..."
: images.length >= 5
? "Max images reached"
: "Generate"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -66,7 +66,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
</Link> </Link>
)} )}
<Link <Link
href="/integrations" href="/store/integrations"
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white" className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"
> >
<IconIntegrations className="h-6 w-6" /> <IconIntegrations className="h-6 w-6" />
@ -121,7 +121,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
</Link> </Link>
)} )}
<Link <Link
href="/integrations" href="/store/integrations"
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white" className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"
> >
<IconIntegrations className="h-6 w-6" /> <IconIntegrations className="h-6 w-6" />

View File

@ -32,7 +32,7 @@ export const StoreCard: React.FC<StoreCardProps> = ({
return ( return (
<div <div
className="inline-flex w-full max-w-[434px] cursor-pointer flex-col items-start justify-start gap-2.5 rounded-[26px] bg-white transition-all duration-300 hover:shadow-lg dark:bg-gray-800 dark:hover:shadow-gray-700" className="inline-flex w-full max-w-[434px] cursor-pointer flex-col items-start justify-start gap-2.5 rounded-[26px] bg-white transition-all duration-300 hover:shadow-lg dark:bg-transparent dark:hover:shadow-gray-700"
onClick={handleClick} onClick={handleClick}
data-testid="store-card" data-testid="store-card"
role="button" role="button"

View File

@ -34,17 +34,19 @@ export const AgentsSection: React.FC<AgentsSectionProps> = ({
}) => { }) => {
const router = useRouter(); const router = useRouter();
// Take only the first 9 agents // TODO: Update this when we have pagination
const displayedAgents = allAgents.slice(0, 9); const displayedAgents = allAgents;
const handleCardClick = (creator: string, slug: string) => { const handleCardClick = (creator: string, slug: string) => {
router.push(`/store/agent/${creator}/${slug}`); router.push(
`/store/agent/${encodeURIComponent(creator)}/${encodeURIComponent(slug)}`,
);
}; };
return ( return (
<div className="flex flex-col items-center justify-center py-4 lg:py-8"> <div className="flex flex-col items-center justify-center py-4 lg:py-8">
<div className="w-full max-w-[1360px]"> <div className="w-full max-w-[1360px]">
<div className="mb-6 font-neue text-[23px] font-bold leading-9 tracking-tight text-[#282828] dark:text-neutral-200"> <div className="font-poppins decoration-skip-ink-none mb-8 text-left text-[18px] font-[600] leading-7 text-[#282828] underline-offset-[from-font] dark:text-neutral-200">
{sectionTitle} {sectionTitle}
</div> </div>
{!displayedAgents || displayedAgents.length === 0 ? ( {!displayedAgents || displayedAgents.length === 0 ? (

View File

@ -24,7 +24,7 @@ export const FeaturedCreators: React.FC<FeaturedCreatorsProps> = ({
const router = useRouter(); const router = useRouter();
const handleCardClick = (creator: string) => { const handleCardClick = (creator: string) => {
router.push(`/store/creator/${creator}`); router.push(`/store/creator/${encodeURIComponent(creator)}`);
}; };
// Only show first 4 creators // Only show first 4 creators

View File

@ -40,7 +40,9 @@ export const FeaturedSection: React.FC<FeaturedSectionProps> = ({
const router = useRouter(); const router = useRouter();
const handleCardClick = (creator: string, slug: string) => { const handleCardClick = (creator: string, slug: string) => {
router.push(`/store/agent/${creator}/${slug}`); router.push(
`/store/agent/${encodeURIComponent(creator)}/${encodeURIComponent(slug)}`,
);
}; };
const handlePrevSlide = useCallback(() => { const handlePrevSlide = useCallback(() => {
@ -113,7 +115,7 @@ export const FeaturedSection: React.FC<FeaturedSectionProps> = ({
/> />
))} ))}
</div> </div>
<div className="flex items-center gap-3"> <div className="mb-[60px] flex items-center gap-3">
<button <button
onClick={handlePrevSlide} onClick={handlePrevSlide}
className="mb:h-12 mb:w-12 flex h-10 w-10 items-center justify-center rounded-full border border-neutral-400 bg-white dark:border-neutral-600 dark:bg-neutral-800" className="mb:h-12 mb:w-12 flex h-10 w-10 items-center justify-center rounded-full border border-neutral-400 bg-white dark:border-neutral-600 dark:bg-neutral-800"

View File

@ -17,6 +17,7 @@ import {
} from "@/lib/autogpt-server-api"; } from "@/lib/autogpt-server-api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useBackendAPI } from "@/lib/autogpt-server-api/context"; import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useToast } from "@/components/ui/use-toast";
interface PublishAgentPopoutProps { interface PublishAgentPopoutProps {
trigger?: React.ReactNode; trigger?: React.ReactNode;
openPopout?: boolean; openPopout?: boolean;
@ -44,6 +45,17 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
); );
const [myAgents, setMyAgents] = React.useState<MyAgentsResponse | null>(null); const [myAgents, setMyAgents] = React.useState<MyAgentsResponse | null>(null);
const [selectedAgent, setSelectedAgent] = React.useState<string | null>(null); const [selectedAgent, setSelectedAgent] = React.useState<string | null>(null);
const [initialData, setInitialData] = React.useState<{
agent_id: string;
title: string;
subheader: string;
slug: string;
thumbnailSrc: string;
youtubeLink: string;
category: string;
description: string;
additionalImages?: string[];
} | null>(null);
const [publishData, setPublishData] = const [publishData, setPublishData] =
React.useState<StoreSubmissionRequest>(submissionData); React.useState<StoreSubmissionRequest>(submissionData);
const [selectedAgentId, setSelectedAgentId] = React.useState<string | null>( const [selectedAgentId, setSelectedAgentId] = React.useState<string | null>(
@ -58,6 +70,8 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
const router = useRouter(); const router = useRouter();
const api = useBackendAPI(); const api = useBackendAPI();
const { toast } = useToast();
React.useEffect(() => { React.useEffect(() => {
console.log("PublishAgentPopout Effect"); console.log("PublishAgentPopout Effect");
setOpen(openPopout); setOpen(openPopout);
@ -102,6 +116,24 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
}; };
const handleNextFromSelect = (agentId: string, agentVersion: number) => { const handleNextFromSelect = (agentId: string, agentVersion: number) => {
const selectedAgentData = myAgents?.agents.find(
(agent) => agent.agent_id === agentId,
);
const name = selectedAgentData?.agent_name || "";
const description = selectedAgentData?.description || "";
setInitialData({
agent_id: agentId,
title: name,
subheader: "",
description: description,
thumbnailSrc: "",
youtubeLink: "",
category: "",
slug: name.replace(/ /g, "-"),
additionalImages: [],
});
setStep("info"); setStep("info");
setSelectedAgentId(agentId); setSelectedAgentId(agentId);
setSelectedAgentVersion(agentVersion); setSelectedAgentVersion(agentVersion);
@ -116,14 +148,20 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
videoUrl: string, videoUrl: string,
categories: string[], categories: string[],
) => { ) => {
if ( const missingFields: string[] = [];
!name ||
!subHeading || if (!name) missingFields.push("Name");
!description || if (!subHeading) missingFields.push("Sub-heading");
!imageUrls.length || if (!description) missingFields.push("Description");
!categories.length if (!imageUrls.length) missingFields.push("Image");
) { if (!categories.length) missingFields.push("Categories");
console.error("Missing required fields");
if (missingFields.length > 0) {
toast({
title: "Missing Required Fields",
description: `Please fill in: ${missingFields.join(", ")}`,
duration: 3000,
});
return; return;
} }
@ -203,6 +241,7 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
onBack={handleBack} onBack={handleBack}
onSubmit={handleNextFromInfo} onSubmit={handleNextFromInfo}
onClose={handleClose} onClose={handleClose}
initialData={initialData}
/> />
</div> </div>
</div> </div>

View File

@ -0,0 +1,37 @@
import { cn } from "@/lib/utils";
import Link from "next/link";
interface Props {
className?: string;
text: string;
linkText?: string;
href?: string;
}
export default function AuthBottomText({
className = "",
text,
linkText,
href = "",
}: Props) {
return (
<div
className={cn(
className,
"mt-8 inline-flex w-full items-center justify-center",
)}
>
<span className="text-sm font-medium leading-normal text-slate-950">
{text}
</span>
{linkText && (
<Link
href={href}
className="ml-1 text-sm font-medium leading-normal text-slate-950 underline"
>
{linkText}
</Link>
)}
</div>
);
}

View File

@ -0,0 +1,36 @@
import { ReactNode } from "react";
import { Button } from "../ui/button";
import { FaSpinner } from "react-icons/fa";
interface Props {
children?: ReactNode;
onClick: () => void;
isLoading?: boolean;
disabled?: boolean;
type?: "button" | "submit" | "reset";
}
export default function AuthButton({
children,
onClick,
isLoading = false,
disabled = false,
type = "button",
}: Props) {
return (
<Button
className="mt-2 w-full self-stretch rounded-md bg-slate-900 px-4 py-2"
type={type}
disabled={isLoading || disabled}
onClick={onClick}
>
{isLoading ? (
<FaSpinner className="animate-spin" />
) : (
<div className="text-sm font-medium leading-normal text-slate-50">
{children}
</div>
)}
</Button>
);
}

View File

@ -0,0 +1,15 @@
import { ReactNode } from "react";
interface Props {
children: ReactNode;
}
export default function AuthCard({ children }: Props) {
return (
<div className="flex h-[80vh] w-[32rem] items-center justify-center">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-md">
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
interface Props {
message?: string | null;
isError?: boolean;
}
export default function AuthFeedback({ message = "", isError = false }: Props) {
return (
<div className="mt-4 text-center text-sm font-medium leading-normal">
{isError ? (
<div className="text-red-500">{message}</div>
) : (
<div className="text-slate-950">{message}</div>
)}
</div>
);
}

View File

@ -0,0 +1,13 @@
import { ReactNode } from "react";
interface Props {
children: ReactNode;
}
export default function AuthHeader({ children }: Props) {
return (
<div className="mb-8 text-2xl font-semibold leading-normal text-slate-950">
{children}
</div>
);
}

View File

@ -16,6 +16,7 @@ const PasswordInput = forwardRef<HTMLInputElement, InputProps>(
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
className={cn("hide-password-toggle pr-10", className)} className={cn("hide-password-toggle pr-10", className)}
ref={ref} ref={ref}
title="password"
{...props} {...props}
/> />
<Button <Button
@ -23,8 +24,11 @@ const PasswordInput = forwardRef<HTMLInputElement, InputProps>(
variant="ghost" variant="ghost"
size="sm" size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)} onMouseDown={() => setShowPassword(true)}
onMouseUp={() => setShowPassword(false)}
onMouseLeave={() => setShowPassword(false)}
disabled={disabled} disabled={disabled}
tabIndex={-1}
> >
{showPassword && !disabled ? ( {showPassword && !disabled ? (
<EyeIcon className="h-4 w-4" aria-hidden="true" /> <EyeIcon className="h-4 w-4" aria-hidden="true" />

View File

@ -0,0 +1,15 @@
import AuthBottomText from "./AuthBottomText";
import AuthButton from "./AuthButton";
import AuthCard from "./AuthCard";
import AuthFeedback from "./AuthFeedback";
import AuthHeader from "./AuthHeader";
import { PasswordInput } from "./PasswordInput";
export {
AuthBottomText,
AuthButton,
AuthCard,
AuthFeedback,
AuthHeader,
PasswordInput,
};

View File

@ -4,6 +4,37 @@
transition: border-color 0.3s ease-in-out; transition: border-color 0.3s ease-in-out;
} }
.custom-node [data-id="input-handles"],
.custom-node [data-id="input-handles"] > div > div {
margin-bottom: 1rem;
}
.custom-node input:not([type="checkbox"]),
.custom-node textarea,
.custom-node select,
.custom-node [data-id^="date-picker"],
.custom-node [data-list-container],
.custom-node [data-add-item],
.custom-node [data-content-settings]. .array-item-container {
display: flex;
align-items: center;
min-width: calc(100% - 2.5rem);
max-width: 100%;
}
.custom-node .custom-switch {
padding: 0.5rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.error-message {
color: #d9534f;
font-size: 13px;
padding-left: 0.5rem;
}
/* Existing styles */ /* Existing styles */
.handle-container { .handle-container {
display: flex; display: flex;

View File

@ -21,6 +21,7 @@ interface SaveControlProps {
agentMeta: GraphMeta | null; agentMeta: GraphMeta | null;
agentName: string; agentName: string;
agentDescription: string; agentDescription: string;
canSave: boolean;
onSave: () => void; onSave: () => void;
onNameChange: (name: string) => void; onNameChange: (name: string) => void;
onDescriptionChange: (description: string) => void; onDescriptionChange: (description: string) => void;
@ -31,6 +32,9 @@ interface SaveControlProps {
* A SaveControl component to be used within the ControlPanel. It allows the user to save the agent. * A SaveControl component to be used within the ControlPanel. It allows the user to save the agent.
* @param {Object} SaveControlProps - The properties of the SaveControl component. * @param {Object} SaveControlProps - The properties of the SaveControl component.
* @param {GraphMeta | null} SaveControlProps.agentMeta - The agent's metadata, or null if creating a new agent. * @param {GraphMeta | null} SaveControlProps.agentMeta - The agent's metadata, or null if creating a new agent.
* @param {string} SaveControlProps.agentName - The agent's name.
* @param {string} SaveControlProps.agentDescription - The agent's description.
* @param {boolean} SaveControlProps.canSave - Whether the button to save the agent should be enabled.
* @param {() => void} SaveControlProps.onSave - Function to save the agent. * @param {() => void} SaveControlProps.onSave - Function to save the agent.
* @param {(name: string) => void} SaveControlProps.onNameChange - Function to handle name changes. * @param {(name: string) => void} SaveControlProps.onNameChange - Function to handle name changes.
* @param {(description: string) => void} SaveControlProps.onDescriptionChange - Function to handle description changes. * @param {(description: string) => void} SaveControlProps.onDescriptionChange - Function to handle description changes.
@ -38,6 +42,7 @@ interface SaveControlProps {
*/ */
export const SaveControl = ({ export const SaveControl = ({
agentMeta, agentMeta,
canSave,
onSave, onSave,
agentName, agentName,
onNameChange, onNameChange,
@ -152,6 +157,7 @@ export const SaveControl = ({
onClick={handleSave} onClick={handleSave}
data-id="save-control-save-agent" data-id="save-control-save-agent"
data-testid="save-control-save-agent-button" data-testid="save-control-save-agent-button"
disabled={!canSave}
> >
Save Agent Save Agent
</Button> </Button>

View File

@ -25,7 +25,7 @@ export function NavBarButtons({ className }: { className?: string }) {
}, },
{ {
href: "/store", href: "/store",
text: "Agent Store", text: "Marketplace",
icon: <IconMarketplace />, icon: <IconMarketplace />,
}, },
]; ];

View File

@ -201,7 +201,7 @@ export const NodeGenericInputField: FC<{
className, className,
displayName, displayName,
}) => { }) => {
className = cn(className, "my-2"); className = cn(className);
displayName ||= propSchema.title || beautifyString(propKey); displayName ||= propSchema.title || beautifyString(propKey);
if ("allOf" in propSchema) { if ("allOf" in propSchema) {
@ -876,8 +876,7 @@ const NodeArrayInput: FC<{
(c) => c.targetHandle === entryKey && c.target === nodeId, (c) => c.targetHandle === entryKey && c.target === nodeId,
); );
return ( return (
<div key={entryKey} className="self-start"> <div key={entryKey}>
<div className="mb-2 flex space-x-2">
<NodeHandle <NodeHandle
keyName={entryKey} keyName={entryKey}
schema={schema.items!} schema={schema.items!}
@ -885,9 +884,11 @@ const NodeArrayInput: FC<{
isRequired={false} isRequired={false}
side="left" side="left"
/> />
<div className="mb-2 flex space-x-2">
{!isConnected && {!isConnected &&
(schema.items ? ( (schema.items ? (
<NodeGenericInputField <NodeGenericInputField
className="w-full"
nodeId={nodeId} nodeId={nodeId}
propKey={entryKey} propKey={entryKey}
propSchema={schema.items} propSchema={schema.items}

View File

@ -1098,9 +1098,13 @@ export function StarRatingIcons(avgRating: number): JSX.Element[] {
const rating = Math.max(0, Math.min(5, avgRating)); const rating = Math.max(0, Math.min(5, avgRating));
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
if (i <= rating) { if (i <= rating) {
stars.push(<IconStarFilled key={i} className="text-black" />); stars.push(
<IconStarFilled key={i} className="text-black dark:text-yellow-500" />,
);
} else { } else {
stars.push(<IconStar key={i} className="text-black" />); stars.push(
<IconStar key={i} className="text-black dark:text-yellow-500" />,
);
} }
} }
return stars; return stars;

View File

@ -169,7 +169,7 @@ export default function useAgentGraph(
inputSchema: block.inputSchema, inputSchema: block.inputSchema,
outputSchema: block.outputSchema, outputSchema: block.outputSchema,
hardcodedValues: node.input_default, hardcodedValues: node.input_default,
webhookId: node.webhook_id, webhook: node.webhook,
uiType: block.uiType, uiType: block.uiType,
connections: graph.links connections: graph.links
.filter((l) => [l.source_id, l.sink_id].includes(node.id)) .filter((l) => [l.source_id, l.sink_id].includes(node.id))
@ -815,7 +815,7 @@ export default function useAgentGraph(
), ),
status: undefined, status: undefined,
backend_id: backendNode.id, backend_id: backendNode.id,
webhookId: backendNode.webhook_id, webhook: backendNode.webhook,
executionResults: [], executionResults: [],
}, },
} }
@ -865,6 +865,9 @@ export default function useAgentGraph(
}, [_saveAgent, toast]); }, [_saveAgent, toast]);
const requestSave = useCallback(() => { const requestSave = useCallback(() => {
if (saveRunRequest.state !== "none") {
return;
}
saveAgent(); saveAgent();
setSaveRunRequest({ setSaveRunRequest({
request: "save", request: "save",

View File

@ -28,7 +28,11 @@ export default function useSupabase() {
const response = await supabase.auth.getUser(); const response = await supabase.auth.getUser();
if (response.error) { if (response.error) {
// Display error only if it's not about missing auth session (user is not logged in)
if (response.error.message !== "Auth session missing!") {
console.error("Error fetching user", response.error); console.error("Error fetching user", response.error);
}
setUser(null);
} else { } else {
setUser(response.data.user); setUser(response.data.user);
} }

View File

@ -277,7 +277,11 @@ export default class BackendAPI {
username: string, username: string,
agentName: string, agentName: string,
): Promise<StoreAgentDetails> { ): Promise<StoreAgentDetails> {
return this._get(`/store/agents/${username}/${agentName}`); return this._get(
`/store/agents/${encodeURIComponent(username)}/${encodeURIComponent(
agentName,
)}`,
);
} }
getStoreCreators(params?: { getStoreCreators(params?: {
@ -291,7 +295,7 @@ export default class BackendAPI {
} }
getStoreCreator(username: string): Promise<CreatorDetails> { getStoreCreator(username: string): Promise<CreatorDetails> {
return this._get(`/store/creator/${username}`); return this._get(`/store/creator/${encodeURIComponent(username)}`);
} }
getStoreSubmissions(params?: { getStoreSubmissions(params?: {
@ -307,6 +311,15 @@ export default class BackendAPI {
return this._request("POST", "/store/submissions", submission); return this._request("POST", "/store/submissions", submission);
} }
generateStoreSubmissionImage(
agent_id: string,
): Promise<{ image_url: string }> {
return this._request(
"POST",
"/store/submissions/generate_image?agent_id=" + agent_id,
);
}
deleteStoreSubmission(submission_id: string): Promise<boolean> { deleteStoreSubmission(submission_id: string): Promise<boolean> {
return this._request("DELETE", `/store/submissions/${submission_id}`); return this._request("DELETE", `/store/submissions/${submission_id}`);
} }
@ -329,7 +342,9 @@ export default class BackendAPI {
console.log("Reviewing agent: ", username, agentName, review); console.log("Reviewing agent: ", username, agentName, review);
return this._request( return this._request(
"POST", "POST",
`/store/agents/${username}/${agentName}/review`, `/store/agents/${encodeURIComponent(username)}/${encodeURIComponent(
agentName,
)}/review`,
review, review,
); );
} }
@ -450,16 +465,6 @@ export default class BackendAPI {
await new Promise((resolve) => setTimeout(resolve, 100 * retryCount)); await new Promise((resolve) => setTimeout(resolve, 100 * retryCount));
} }
} }
console.debug("Request: ", method, path, "from: ", page);
if (token === "no-token-found") {
console.warn(
"No auth token found after retries. This may indicate a session sync issue between client and server.",
);
console.debug("Last session attempt:", retryCount);
} else {
console.log("Auth token found");
}
console.log("--------------------------------");
let url = this.baseUrl + path; let url = this.baseUrl + path;
const payloadAsQuery = ["GET", "DELETE"].includes(method); const payloadAsQuery = ["GET", "DELETE"].includes(method);

View File

@ -172,7 +172,7 @@ export type Node = {
position: { x: number; y: number }; position: { x: number; y: number };
[key: string]: any; [key: string]: any;
}; };
webhook_id?: string; webhook?: Webhook;
}; };
/* Mirror of backend/data/graph.py:Link */ /* Mirror of backend/data/graph.py:Link */
@ -314,6 +314,20 @@ export type APIKeyCredentials = BaseCredentials & {
expires_at?: number; expires_at?: number;
}; };
/* Mirror of backend/data/integrations.py:Webhook */
type Webhook = {
id: string;
url: string;
provider: CredentialsProviderName;
credentials_id: string;
webhook_type: string;
resource?: string;
events: string[];
secret: string;
config: Record<string, any>;
provider_webhook_id?: string;
};
export type User = { export type User = {
id: string; id: string;
email: string; email: string;
@ -325,6 +339,7 @@ export enum BlockUIType {
OUTPUT = "Output", OUTPUT = "Output",
NOTE = "Note", NOTE = "Note",
WEBHOOK = "Webhook", WEBHOOK = "Webhook",
WEBHOOK_MANUAL = "Webhook (manual)",
AGENT = "Agent", AGENT = "Agent",
} }
@ -473,6 +488,7 @@ export type MyAgent = {
agent_version: number; agent_version: number;
agent_name: string; agent_name: string;
last_edited: string; last_edited: string;
description: string;
}; };
export type MyAgentsResponse = { export type MyAgentsResponse = {

View File

@ -1,7 +1,8 @@
import { Graph, Block, Node } from "./types"; import { Graph, Block, Node, BlockUIType } from "./types";
/** Creates a copy of the graph with all secrets removed */ /** Creates a copy of the graph with all secrets removed */
export function safeCopyGraph(graph: Graph, block_defs: Block[]): Graph { export function safeCopyGraph(graph: Graph, block_defs: Block[]): Graph {
graph = removeAgentInputBlockValues(graph, block_defs);
return { return {
...graph, ...graph,
nodes: graph.nodes.map((node) => { nodes: graph.nodes.map((node) => {
@ -18,3 +19,28 @@ export function safeCopyGraph(graph: Graph, block_defs: Block[]): Graph {
}), }),
}; };
} }
export function removeAgentInputBlockValues(graph: Graph, blocks: Block[]) {
const inputBlocks = graph.nodes.filter(
(node) =>
blocks.find((b) => b.id === node.block_id)?.uiType === BlockUIType.INPUT,
);
const modifiedNodes = graph.nodes.map((node) => {
if (inputBlocks.find((inputNode) => inputNode.id === node.id)) {
return {
...node,
input_default: {
...node.input_default,
value: "",
},
};
}
return node;
});
return {
...graph,
nodes: modifiedNodes,
};
}

View File

@ -120,9 +120,28 @@ const applyExceptions = (str: string): string => {
return str; return str;
}; };
/** Recursively remove all "credentials" properties from exported JSON files */
export function removeCredentials(obj: any) {
if (obj && typeof obj === "object") {
if (Array.isArray(obj)) {
obj.forEach((item) => removeCredentials(item));
} else {
delete obj.credentials;
Object.values(obj).forEach((value) => removeCredentials(value));
}
}
return obj;
}
export function exportAsJSONFile(obj: object, filename: string): void { export function exportAsJSONFile(obj: object, filename: string): void {
// Deep clone the object to avoid modifying the original
const sanitizedObj = JSON.parse(JSON.stringify(obj));
// Sanitize the object
removeCredentials(sanitizedObj);
// Create downloadable blob // Create downloadable blob
const jsonString = JSON.stringify(obj, null, 2); const jsonString = JSON.stringify(sanitizedObj, null, 2);
const blob = new Blob([jsonString], { type: "application/json" }); const blob = new Blob([jsonString], { type: "application/json" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);

View File

@ -7,33 +7,28 @@ export class LoginPage {
console.log("Attempting login with:", { email, password }); // Debug log console.log("Attempting login with:", { email, password }); // Debug log
// Fill email // Fill email
const emailInput = this.page.getByPlaceholder("user@email.com"); const emailInput = this.page.getByPlaceholder("m@example.com");
await emailInput.waitFor({ state: "visible" }); await emailInput.waitFor({ state: "visible" });
await emailInput.fill(email); await emailInput.fill(email);
// Fill password // Fill password
const passwordInput = this.page.getByPlaceholder("password"); const passwordInput = this.page.getByTitle("Password");
await passwordInput.waitFor({ state: "visible" }); await passwordInput.waitFor({ state: "visible" });
await passwordInput.fill(password); await passwordInput.fill(password);
// Check terms
const termsCheckbox = this.page.getByLabel("I agree to the Terms of Use");
await termsCheckbox.waitFor({ state: "visible" });
await termsCheckbox.click();
// TODO: This is a workaround to wait for the page to load after filling the email and password // TODO: This is a workaround to wait for the page to load after filling the email and password
const emailInput2 = this.page.getByPlaceholder("user@email.com"); const emailInput2 = this.page.getByPlaceholder("m@example.com");
await emailInput2.waitFor({ state: "visible" }); await emailInput2.waitFor({ state: "visible" });
await emailInput2.fill(email); await emailInput2.fill(email);
// Fill password // Fill password
const passwordInput2 = this.page.getByPlaceholder("password"); const passwordInput2 = this.page.getByTitle("Password");
await passwordInput2.waitFor({ state: "visible" }); await passwordInput2.waitFor({ state: "visible" });
await passwordInput2.fill(password); await passwordInput2.fill(password);
// Wait for the button to be ready // Wait for the button to be ready
const loginButton = this.page.getByRole("button", { const loginButton = this.page.getByRole("button", {
name: "Log in", name: "Login",
exact: true, exact: true,
}); });
await loginButton.waitFor({ state: "visible" }); await loginButton.waitFor({ state: "visible" });

View File

@ -9,15 +9,15 @@ export class NavBar {
} }
async clickMonitorLink() { async clickMonitorLink() {
await this.page.getByRole("link", { name: "Library" }).click(); await this.page.getByTestId("navbar-link-library").click();
} }
async clickBuildLink() { async clickBuildLink() {
await this.page.locator('a[href="/build"] div').click(); await this.page.getByTestId("navbar-link-build").click();
} }
async clickMarketplaceLink() { async clickMarketplaceLink() {
await this.page.locator('a[href="/store"]').click(); await this.page.getByTestId("navbar-link-marketplace").click();
} }
async getUserMenuButton() { async getUserMenuButton() {
@ -25,7 +25,7 @@ export class NavBar {
} }
async clickUserMenu() { async clickUserMenu() {
await this.page.getByTestId("profile-popout-menu-trigger").click(); await (await this.getUserMenuButton()).click();
} }
async logout() { async logout() {
@ -35,7 +35,9 @@ export class NavBar {
async isLoggedIn(): Promise<boolean> { async isLoggedIn(): Promise<boolean> {
try { try {
await this.page.getByTestId("profile-popout-menu-trigger").waitFor({ await (
await this.getUserMenuButton()
).waitFor({
state: "visible", state: "visible",
timeout: 10_000, timeout: 10_000,
}); });

View File

@ -0,0 +1,63 @@
import { z } from "zod";
export type LoginProvider = "google" | "github" | "discord";
export const loginFormSchema = z.object({
email: z
.string()
.email()
.max(128, "Email must contain at most 128 characters")
.trim(),
password: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
});
export const signupFormSchema = z
.object({
email: z
.string()
.email()
.max(128, "Email must contain at most 128 characters")
.trim(),
password: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
confirmPassword: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
agreeToTerms: z.boolean().refine((value) => value === true, {
message: "You must agree to the Terms of Use and Privacy Policy",
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export const sendEmailFormSchema = z.object({
email: z
.string()
.email()
.max(128, "Email must contain at most 128 characters")
.trim(),
});
export const changePasswordFormSchema = z
.object({
password: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
confirmPassword: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});

View File

@ -1018,10 +1018,10 @@
"@types/tough-cookie" "^4.0.5" "@types/tough-cookie" "^4.0.5"
tough-cookie "^4.1.4" tough-cookie "^4.1.4"
"@chromatic-com/storybook@^3.2.2": "@chromatic-com/storybook@^3.2.3":
version "3.2.2" version "3.2.3"
resolved "https://registry.yarnpkg.com/@chromatic-com/storybook/-/storybook-3.2.2.tgz#08754443de55618f802f88450c35266fd6d25db5" resolved "https://registry.yarnpkg.com/@chromatic-com/storybook/-/storybook-3.2.3.tgz#0f4d167ac80fcb38293a92c230c43446049b6758"
integrity sha512-xmXt/GW0hAPbzNTrxYuVo43Adrtjue4DeVrsoIIEeJdGaPNNeNf+DHMlJKOBdlHmCnFUoe9R/0mLM9zUp5bKWw== integrity sha512-3+hfANx79kIjP1qrOSLxpoAXOiYUA0S7A0WI0A24kASrv7USFNNW8etR5TjUilMb0LmqKUn3wDwUK2h6aceQ9g==
dependencies: dependencies:
chromatic "^11.15.0" chromatic "^11.15.0"
filesize "^10.0.12" filesize "^10.0.12"
@ -1698,10 +1698,10 @@
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.20.tgz#0be2cc955f4eb837516e7d7382284cd5bc1d5a02" resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.20.tgz#0be2cc955f4eb837516e7d7382284cd5bc1d5a02"
integrity sha512-JfDpuOCB0UBKlEgEy/H6qcBSzHimn/YWjUHzKl1jMeUO+QVRdzmTTl8gFJaNO87c8DXmVKhFCtwxQ9acqB3+Pw== integrity sha512-JfDpuOCB0UBKlEgEy/H6qcBSzHimn/YWjUHzKl1jMeUO+QVRdzmTTl8gFJaNO87c8DXmVKhFCtwxQ9acqB3+Pw==
"@next/eslint-plugin-next@15.1.0": "@next/eslint-plugin-next@15.1.3":
version "15.1.0" version "15.1.3"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.0.tgz#421b47ad0772e11b2d67416054675cd32f95b8b7" resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.3.tgz#32777736af151577df52d83f25c0c22bc9f3cb5e"
integrity sha512-+jPT0h+nelBT6HC9ZCHGc7DgGVy04cv4shYdAe6tKlEbjQUtwU3LzQhzbDHQyY2m6g39m6B0kOFVuLGBrxxbGg== integrity sha512-oeP1vnc5Cq9UoOb8SYHAEPbCXMzOgG70l+Zfd+Ie00R25FOm+CCVNrcIubJvB1tvBgakXE37MmqSycksXVPRqg==
dependencies: dependencies:
fast-glob "3.3.1" fast-glob "3.3.1"
@ -3213,10 +3213,10 @@
"@storybook/react-dom-shim" "8.4.7" "@storybook/react-dom-shim" "8.4.7"
"@storybook/theming" "8.4.7" "@storybook/theming" "8.4.7"
"@storybook/test-runner@^0.20.1": "@storybook/test-runner@^0.21.0":
version "0.20.1" version "0.21.0"
resolved "https://registry.yarnpkg.com/@storybook/test-runner/-/test-runner-0.20.1.tgz#e2efa6266d512312a6b810db376da2919008cccd" resolved "https://registry.yarnpkg.com/@storybook/test-runner/-/test-runner-0.21.0.tgz#31e7a6878e15a3f4d5555c57a135dd4d13fce9c4"
integrity sha512-3WU/th/uncIR6vpQDK9hKjiZjmczsluoLbgkRV7ufxY9IgHCGcbIjvT5EPS+XZIaOrNGjaPsyB5cE1okKn9iSA== integrity sha512-aG2QvKXSIjMN1CA9PJK/esnidZWgt1gAkfo9Kqf8+NqBSsmP/2GyL5vxu1lkRFO/4qCv5JenNZ5Uj6ie4b2oag==
dependencies: dependencies:
"@babel/core" "^7.22.5" "@babel/core" "^7.22.5"
"@babel/generator" "^7.22.5" "@babel/generator" "^7.22.5"
@ -3258,21 +3258,21 @@
integrity sha512-99rgLEjf7iwfSEmdqlHkSG3AyLcK0sfExcr0jnc6rLiAkBhzuIsvcHjjUwkR210SOCgXqBPW0ZA6uhnuyppHLw== integrity sha512-99rgLEjf7iwfSEmdqlHkSG3AyLcK0sfExcr0jnc6rLiAkBhzuIsvcHjjUwkR210SOCgXqBPW0ZA6uhnuyppHLw==
"@stripe/stripe-js@^5.3.0": "@stripe/stripe-js@^5.3.0":
version "5.3.0" version "5.4.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-5.3.0.tgz#56f9fe2ac0df8de714fd3c0c80ea4263d821795d" resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-5.4.0.tgz#847e870ddfe9283432526867857a4c1fba9b11ed"
integrity sha512-lNCZwCak1Yk0x2ecQO+4kcV7MwxAXapfgmLEh5SIoczc/r4GWAmcfyXZu3AAle+MAVW9HBe6f7tywuxJtYomcg== integrity sha512-3tfMbSvLGB+OsJ2MsjWjWo+7sp29dwx+3+9kG/TEnZQJt+EwbF/Nomm43cSK+6oXZA9uhspgyrB+BbrPRumx4g==
"@supabase/auth-js@2.67.1": "@supabase/auth-js@2.67.3":
version "2.67.1" version "2.67.3"
resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.67.1.tgz#b72217136df61d645dcfb7b12c7db8cbb7875a4c" resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.67.3.tgz#a1f5eb22440b0cdbf87fe2ecae662a8dd8bb2028"
integrity sha512-1SRZG9VkLFz4rtiyEc1l49tMq9jTYu4wJt3pMQEWi7yshZFIBdVH1o5sshk1plQd5LY6GcrPIpCydM2gGDxchA== integrity sha512-NJDaW8yXs49xMvWVOkSIr8j46jf+tYHV0wHhrwOaLLMZSFO4g6kKAf+MfzQ2RaD06OCUkUHIzctLAxjTgEVpzw==
dependencies: dependencies:
"@supabase/node-fetch" "^2.6.14" "@supabase/node-fetch" "^2.6.14"
"@supabase/functions-js@2.4.3": "@supabase/functions-js@2.4.4":
version "2.4.3" version "2.4.4"
resolved "https://registry.yarnpkg.com/@supabase/functions-js/-/functions-js-2.4.3.tgz#ac1c696d3a1ebe00f60d5cea69b208078678ef8b" resolved "https://registry.yarnpkg.com/@supabase/functions-js/-/functions-js-2.4.4.tgz#45fcd94d546bdfa66d01f93a796ca0304ec154b8"
integrity sha512-sOLXy+mWRyu4LLv1onYydq+10mNRQ4rzqQxNhbrKLTLTcdcmS9hbWif0bGz/NavmiQfPs4ZcmQJp4WqOXlR4AQ== integrity sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==
dependencies: dependencies:
"@supabase/node-fetch" "^2.6.14" "@supabase/node-fetch" "^2.6.14"
@ -3316,12 +3316,12 @@
"@supabase/node-fetch" "^2.6.14" "@supabase/node-fetch" "^2.6.14"
"@supabase/supabase-js@^2.47.8": "@supabase/supabase-js@^2.47.8":
version "2.47.8" version "2.47.10"
resolved "https://registry.yarnpkg.com/@supabase/supabase-js/-/supabase-js-2.47.8.tgz#6471a356b694e14170a00e6582bdbd0126944ec6" resolved "https://registry.yarnpkg.com/@supabase/supabase-js/-/supabase-js-2.47.10.tgz#310ce81dc734116f9445dbce7f9341ae1c24d834"
integrity sha512-2GjK8/PrGJYDVBcjqGyM2irBLMQXvvkJLbS8VFPlym2uuNz+pPMnwLbNf5njkknUTy3PamjgIRoADpuPPPA6oA== integrity sha512-vJfPF820Ho5WILYHfKiBykDQ1SB9odTHrRZ0JxHfuLMC8GRvv21YLkUZQK7/rSVCkLvD6/ZwMWaOAfdUd//guw==
dependencies: dependencies:
"@supabase/auth-js" "2.67.1" "@supabase/auth-js" "2.67.3"
"@supabase/functions-js" "2.4.3" "@supabase/functions-js" "2.4.4"
"@supabase/node-fetch" "2.6.15" "@supabase/node-fetch" "2.6.15"
"@supabase/postgrest-js" "1.17.7" "@supabase/postgrest-js" "1.17.7"
"@supabase/realtime-js" "2.11.2" "@supabase/realtime-js" "2.11.2"
@ -5197,10 +5197,10 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
concurrently@^9.0.1: concurrently@^9.1.1:
version "9.1.0" version "9.1.1"
resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.1.0.tgz#8da6d609f4321752912dab9be8710232ac496aa0" resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.1.1.tgz#609dde2ce12f4f12d6a5ea6eace4c38bb7ab2ead"
integrity sha512-VxkzwMAn4LP7WyMnJNbHN5mKV9L2IbyDjpzemKr99sXNR3GqRNMMHdm7prV1ws9wg7ETj6WUkNOigZVsptwbgg== integrity sha512-6VX8lrBIycgZKTwBsWS+bLrmkGRkDmvtGsYylRN9b93CygN6CbK46HmnQ3rdSOR8HRjdahDrxb5MqD9cEFOg5Q==
dependencies: dependencies:
chalk "^4.1.2" chalk "^4.1.2"
lodash "^4.17.21" lodash "^4.17.21"
@ -6125,12 +6125,12 @@ escape-string-regexp@^4.0.0:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
eslint-config-next@15.1.0: eslint-config-next@15.1.3:
version "15.1.0" version "15.1.3"
resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-15.1.0.tgz#25a9a076b059905fd0cf3f6f832771724dfcbbdf" resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-15.1.3.tgz#7656b47591745bcdbd60d396282924d89f82eea6"
integrity sha512-gADO+nKVseGso3DtOrYX9H7TxB/MuX7AUYhMlvQMqLYvUWu4HrOQuU7cC1HW74tHIqkAvXdwgAz3TCbczzSEXw== integrity sha512-wGYlNuWnh4ujuKtZvH+7B2Z2vy9nONZE6ztd+DKF7hAsIabkrxmD4TzYHzASHENo42lmz2tnT2B+zN2sOHvpJg==
dependencies: dependencies:
"@next/eslint-plugin-next" "15.1.0" "@next/eslint-plugin-next" "15.1.3"
"@rushstack/eslint-patch" "^1.10.3" "@rushstack/eslint-patch" "^1.10.3"
"@typescript-eslint/eslint-plugin" "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0" "@typescript-eslint/eslint-plugin" "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0"
"@typescript-eslint/parser" "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0" "@typescript-eslint/parser" "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0"
@ -11050,10 +11050,10 @@ tailwindcss-animate@^1.0.7:
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4" resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"
integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA== integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==
tailwindcss@^3.4.15: tailwindcss@^3.4.17:
version "3.4.16" version "3.4.17"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.16.tgz#35a7c3030844d6000fc271878db4096b6a8d2ec9" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63"
integrity sha512-TI4Cyx7gDiZ6r44ewaJmt0o6BrMCT5aK5e0rmJ/G9Xq3w7CX/5VXl/zIPEJZFUK5VEqwByyhqNPycPlvcK4ZNw== integrity sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==
dependencies: dependencies:
"@alloc/quick-lru" "^5.2.0" "@alloc/quick-lru" "^5.2.0"
arg "^5.0.2" arg "^5.0.2"

View File

@ -829,13 +829,13 @@ tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
[[package]] [[package]]
name = "pyright" name = "pyright"
version = "1.1.390" version = "1.1.391"
description = "Command line wrapper for pyright" description = "Command line wrapper for pyright"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "pyright-1.1.390-py3-none-any.whl", hash = "sha256:ecebfba5b6b50af7c1a44c2ba144ba2ab542c227eb49bc1f16984ff714e0e110"}, {file = "pyright-1.1.391-py3-none-any.whl", hash = "sha256:54fa186f8b3e8a55a44ebfa842636635688670c6896dcf6cf4a7fc75062f4d15"},
{file = "pyright-1.1.390.tar.gz", hash = "sha256:aad7f160c49e0fbf8209507a15e17b781f63a86a1facb69ca877c71ef2e9538d"}, {file = "pyright-1.1.391.tar.gz", hash = "sha256:66b2d42cdf5c3cbab05f2f4b76e8bec8aa78e679bfa0b6ad7b923d9e027cadb2"},
] ]
[package.dependencies] [package.dependencies]
@ -871,13 +871,13 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments
[[package]] [[package]]
name = "pytest-asyncio" name = "pytest-asyncio"
version = "0.25.0" version = "0.25.1"
description = "Pytest support for asyncio" description = "Pytest support for asyncio"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "pytest_asyncio-0.25.0-py3-none-any.whl", hash = "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3"}, {file = "pytest_asyncio-0.25.1-py3-none-any.whl", hash = "sha256:c84878849ec63ff2ca509423616e071ef9cd8cc93c053aa33b5b8fb70a990671"},
{file = "pytest_asyncio-0.25.0.tar.gz", hash = "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609"}, {file = "pytest_asyncio-0.25.1.tar.gz", hash = "sha256:79be8a72384b0c917677e00daa711e07db15259f4d23203c59012bcd989d4aee"},
] ]
[package.dependencies] [package.dependencies]
@ -1058,29 +1058,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.8.3" version = "0.8.4"
description = "An extremely fast Python linter and code formatter, written in Rust." description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6"}, {file = "ruff-0.8.4-py3-none-linux_armv6l.whl", hash = "sha256:58072f0c06080276804c6a4e21a9045a706584a958e644353603d36ca1eb8a60"},
{file = "ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939"}, {file = "ruff-0.8.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ffb60904651c00a1e0b8df594591770018a0f04587f7deeb3838344fe3adabac"},
{file = "ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d"}, {file = "ruff-0.8.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ddf5d654ac0d44389f6bf05cee4caeefc3132a64b58ea46738111d687352296"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13"}, {file = "ruff-0.8.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e248b1f0fa2749edd3350a2a342b67b43a2627434c059a063418e3d375cfe643"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18"}, {file = "ruff-0.8.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf197b98ed86e417412ee3b6c893f44c8864f816451441483253d5ff22c0e81e"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502"}, {file = "ruff-0.8.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c41319b85faa3aadd4d30cb1cffdd9ac6b89704ff79f7664b853785b48eccdf3"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d"}, {file = "ruff-0.8.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9f8402b7c4f96463f135e936d9ab77b65711fcd5d72e5d67597b543bbb43cf3f"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82"}, {file = "ruff-0.8.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4e56b3baa9c23d324ead112a4fdf20db9a3f8f29eeabff1355114dd96014604"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452"}, {file = "ruff-0.8.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:736272574e97157f7edbbb43b1d046125fce9e7d8d583d5d65d0c9bf2c15addf"},
{file = "ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd"}, {file = "ruff-0.8.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fe710ab6061592521f902fca7ebcb9fabd27bc7c57c764298b1c1f15fff720"},
{file = "ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20"}, {file = "ruff-0.8.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:13e9ec6d6b55f6da412d59953d65d66e760d583dd3c1c72bf1f26435b5bfdbae"},
{file = "ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc"}, {file = "ruff-0.8.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:97d9aefef725348ad77d6db98b726cfdb075a40b936c7984088804dfd38268a7"},
{file = "ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060"}, {file = "ruff-0.8.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ab78e33325a6f5374e04c2ab924a3367d69a0da36f8c9cb6b894a62017506111"},
{file = "ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea"}, {file = "ruff-0.8.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8ef06f66f4a05c3ddbc9121a8b0cecccd92c5bf3dd43b5472ffe40b8ca10f0f8"},
{file = "ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964"}, {file = "ruff-0.8.4-py3-none-win32.whl", hash = "sha256:552fb6d861320958ca5e15f28b20a3d071aa83b93caee33a87b471f99a6c0835"},
{file = "ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9"}, {file = "ruff-0.8.4-py3-none-win_amd64.whl", hash = "sha256:f21a1143776f8656d7f364bd264a9d60f01b7f52243fbe90e7670c0dfe0cf65d"},
{file = "ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936"}, {file = "ruff-0.8.4-py3-none-win_arm64.whl", hash = "sha256:9183dd615d8df50defa8b1d9a074053891ba39025cf5ae88e8bcb52edcc4bf08"},
{file = "ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3"}, {file = "ruff-0.8.4.tar.gz", hash = "sha256:0d5f89f254836799af1615798caa5f80b7f935d7a670fad66c5007928e57ace8"},
] ]
[[package]] [[package]]
@ -1298,4 +1298,4 @@ watchmedo = ["PyYAML (>=3.10)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "5dbf6cd95ba8e80c4a6b4e6a54c6cdfb1488619e4293d1d5a8572c5330485493" content-hash = "c62380410681d30c5c5da8b047c449c92196f2a25ea5d353db2a3e5470737513"

View File

@ -24,12 +24,12 @@ prometheus-fastapi-instrumentator = "^7.0.0"
autogpt-libs = {path = "../autogpt_libs"} autogpt-libs = {path = "../autogpt_libs"}
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^8.3.4" pytest = "^8.3.4"
pytest-asyncio = "^0.25.0" pytest-asyncio = "^0.25.1"
pytest-watcher = "^0.4.3" pytest-watcher = "^0.4.3"
requests = "^2.32.3" requests = "^2.32.3"
ruff = "^0.8.3" ruff = "^0.8.4"
pyright = "^1.1.390" pyright = "^1.1.391"
isort = "^5.13.2" isort = "^5.13.2"
black = "^24.10.0" black = "^24.10.0"

View File

@ -416,13 +416,13 @@ To create a webhook-triggered block, follow these additional steps on top of the
To add support for a new webhook provider, you'll need to create a WebhooksManager that implements the `BaseWebhooksManager` interface: To add support for a new webhook provider, you'll need to create a WebhooksManager that implements the `BaseWebhooksManager` interface:
```python title="backend/integrations/webhooks/base.py" ```python title="backend/integrations/webhooks/_base.py"
--8<-- "autogpt_platform/backend/backend/integrations/webhooks/base.py:BaseWebhooksManager1" --8<-- "autogpt_platform/backend/backend/integrations/webhooks/_base.py:BaseWebhooksManager1"
--8<-- "autogpt_platform/backend/backend/integrations/webhooks/base.py:BaseWebhooksManager2" --8<-- "autogpt_platform/backend/backend/integrations/webhooks/_base.py:BaseWebhooksManager2"
--8<-- "autogpt_platform/backend/backend/integrations/webhooks/base.py:BaseWebhooksManager3" --8<-- "autogpt_platform/backend/backend/integrations/webhooks/_base.py:BaseWebhooksManager3"
--8<-- "autogpt_platform/backend/backend/integrations/webhooks/base.py:BaseWebhooksManager4" --8<-- "autogpt_platform/backend/backend/integrations/webhooks/_base.py:BaseWebhooksManager4"
--8<-- "autogpt_platform/backend/backend/integrations/webhooks/base.py:BaseWebhooksManager5" --8<-- "autogpt_platform/backend/backend/integrations/webhooks/_base.py:BaseWebhooksManager5"
``` ```
And add a reference to your `WebhooksManager` class in `WEBHOOK_MANAGERS_BY_NAME`: And add a reference to your `WebhooksManager` class in `WEBHOOK_MANAGERS_BY_NAME`: