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,7 +31,8 @@ class RedisKeyedMutex:
try:
yield
finally:
lock.release()
if lock.locked():
lock.release()
def acquire(self, key: Any) -> "RedisLock":
"""Acquires and returns a lock with the given key"""
@ -45,7 +46,7 @@ class RedisKeyedMutex:
return lock
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()
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 typing import List
from pydantic import BaseModel
from backend.blocks.exa._auth import (
ExaCredentials,
ExaCredentialsField,
ExaCredentialsInput,
)
from backend.blocks.exa.helpers import ContentSettings
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
from backend.data.model import SchemaField
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 Input(BlockSchema):
credentials: ExaCredentialsInput = ExaCredentialsField()
query: str = SchemaField(description="The search query")
useAutoprompt: bool = SchemaField(
use_auto_prompt: bool = SchemaField(
description="Whether to use autoprompt",
default=True,
advanced=True,
)
type: str = SchemaField(
description="Type of search",
default="",
advanced=True,
)
category: str = SchemaField(
description="Category to search within",
default="",
advanced=True,
)
numResults: int = SchemaField(
number_of_results: int = SchemaField(
description="Number of results to return",
default=10,
advanced=True,
)
includeDomains: List[str] = SchemaField(
include_domains: List[str] = SchemaField(
description="Domains to include in search",
default=[],
)
excludeDomains: List[str] = SchemaField(
exclude_domains: List[str] = SchemaField(
description="Domains to exclude from search",
default=[],
advanced=True,
)
startCrawlDate: datetime = SchemaField(
start_crawl_date: datetime = SchemaField(
description="Start date for crawled content",
)
endCrawlDate: datetime = SchemaField(
end_crawl_date: datetime = SchemaField(
description="End date for crawled content",
)
startPublishedDate: datetime = SchemaField(
start_published_date: datetime = SchemaField(
description="Start date for published content",
)
endPublishedDate: datetime = SchemaField(
end_published_date: datetime = SchemaField(
description="End date for published content",
)
includeText: List[str] = SchemaField(
include_text: List[str] = SchemaField(
description="Text patterns to include",
default=[],
advanced=True,
)
excludeText: List[str] = SchemaField(
exclude_text: List[str] = SchemaField(
description="Text patterns to exclude",
default=[],
advanced=True,
)
contents: ContentSettings = SchemaField(
description="Content retrieval settings",
default=ContentSettings(),
advanced=True,
)
class Output(BlockSchema):
@ -107,44 +99,38 @@ class ExaSearchBlock(Block):
payload = {
"query": input_data.query,
"useAutoprompt": input_data.useAutoprompt,
"numResults": input_data.numResults,
"contents": {
"text": {"maxCharacters": 1000, "includeHtmlTags": False},
"highlights": {
"numSentences": 3,
"highlightsPerUrl": 3,
},
"summary": {"query": ""},
},
"useAutoprompt": input_data.use_auto_prompt,
"numResults": input_data.number_of_results,
"contents": input_data.contents.dict(),
}
date_field_mapping = {
"start_crawl_date": "startCrawlDate",
"end_crawl_date": "endCrawlDate",
"start_published_date": "startPublishedDate",
"end_published_date": "endPublishedDate",
}
# Add dates if they exist
date_fields = [
"startCrawlDate",
"endCrawlDate",
"startPublishedDate",
"endPublishedDate",
]
for field in date_fields:
value = getattr(input_data, field, None)
for input_field, api_field in date_field_mapping.items():
value = getattr(input_data, input_field, None)
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
optional_fields = [
"type",
"category",
"includeDomains",
"excludeDomains",
"includeText",
"excludeText",
]
for field in optional_fields:
value = getattr(input_data, field)
for input_field, api_field in optional_field_mapping.items():
value = getattr(input_data, input_field)
if value: # Only add non-empty values
payload[field] = value
payload[api_field] = value
try:
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
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()
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"
NOTE = "Note"
WEBHOOK = "Webhook"
WEBHOOK_MANUAL = "Webhook (manual)"
AGENT = "Agent"
@ -57,6 +58,7 @@ class BlockCategory(Enum):
COMMUNICATION = "Block that interacts with communication platforms."
DEVELOPER_TOOLS = "Developer tools such as GitHub blocks."
DATA = "Block that interacts with structured data."
HARDWARE = "Block that interacts with hardware."
AGENT = "Block that interacts with other agents."
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))
# 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
@classmethod
@ -197,7 +194,12 @@ class EmptySchema(BlockSchema):
# --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
"""The service provider that the webhook connects to"""
@ -208,6 +210,27 @@ class BlockWebhookConfig(BaseModel):
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
"""
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`.
"""
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]
@ -247,7 +259,7 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
disabled: bool = False,
static_output: bool = False,
block_type: BlockType = BlockType.STANDARD,
webhook_config: Optional[BlockWebhookConfig] = None,
webhook_config: Optional[BlockWebhookConfig | BlockManualWebhookConfig] = None,
):
"""
Initialize the block with the given schema.
@ -278,27 +290,38 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
self.contributors = contributors or set()
self.disabled = disabled
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.execution_stats = {}
if self.webhook_config:
# Enforce shape of webhook event filter
event_filter_field = self.input_schema.model_fields[
self.webhook_config.event_filter_input
]
if not (
isinstance(event_filter_field.annotation, type)
and issubclass(event_filter_field.annotation, BaseModel)
and all(
field.annotation is bool
for field in event_filter_field.annotation.model_fields.values()
)
):
raise NotImplementedError(
f"{self.name} has an invalid webhook event selector: "
"field must be a BaseModel and all its fields must be boolean"
)
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[
self.webhook_config.event_filter_input
]
if not (
isinstance(event_filter_field.annotation, type)
and issubclass(event_filter_field.annotation, BaseModel)
and all(
field.annotation is bool
for field in event_filter_field.annotation.model_fields.values()
)
):
raise NotImplementedError(
f"{self.name} has an invalid webhook event selector: "
"field must be a BaseModel and all its fields must be boolean"
)
# Enforce presence of 'payload' input
if "payload" not in self.input_schema.model_fields:

View File

@ -84,6 +84,8 @@ class NodeModel(Node):
raise ValueError(f"Block #{self.block_id} not found for node #{self.id}")
if not block.webhook_config:
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)
if not event_filter:
raise ValueError(f"Event filter is not configured on node #{self.id}")
@ -191,7 +193,8 @@ class Graph(BaseDbModel):
"properties": {
p.name: {
"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,
**({"description": p.description} if p.description 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, [])]
)
for name in block.input_schema.get_required_fields():
if name not in provided_inputs and (
for_run # Skip input completion validation, unless when executing.
or block.block_type == BlockType.INPUT
or block.block_type == BlockType.OUTPUT
or block.block_type == BlockType.AGENT
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.
or block.block_type == BlockType.INPUT
or block.block_type == BlockType.OUTPUT
or block.block_type == BlockType.AGENT
)
):
raise ValueError(
f"Node {block.name} #{node.id} required input missing: `{name}`"
@ -292,7 +303,6 @@ class GraphModel(Graph):
# Validate dependencies between fields
for field_name, field_info in input_schema.items():
# Apply input dependency validation only on run & field with depends_on
json_schema_extra = field_info.json_schema_extra or {}
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.
@staticmethod
def from_db(graph: AgentGraph, hide_credentials: bool = False):
def from_db(graph: AgentGraph, for_export: bool = False):
return GraphModel(
id=graph.id,
user_id=graph.userId,
@ -369,7 +379,7 @@ class GraphModel(Graph):
name=graph.name or "",
description=graph.description or "",
nodes=[
GraphModel._process_node(node, hide_credentials)
NodeModel.from_db(GraphModel._process_node(node, for_export))
for node in graph.AgentNodes or []
],
links=list(
@ -382,23 +392,29 @@ class GraphModel(Graph):
)
@staticmethod
def _process_node(node: AgentNode, hide_credentials: bool) -> NodeModel:
node_dict = {field: getattr(node, field) for field in node.model_fields}
if hide_credentials and "constantInput" in node_dict:
constant_input = json.loads(
node_dict["constantInput"], target_type=dict[str, Any]
)
constant_input = GraphModel._hide_credentials_in_input(constant_input)
node_dict["constantInput"] = json.dumps(constant_input)
return NodeModel.from_db(AgentNode(**node_dict))
def _process_node(node: AgentNode, for_export: bool) -> AgentNode:
if for_export:
# Remove credentials from node input
if node.constantInput:
constant_input = json.loads(
node.constantInput, target_type=dict[str, Any]
)
constant_input = GraphModel._hide_node_input_credentials(constant_input)
node.constantInput = json.dumps(constant_input)
# Remove webhook info
node.webhookId = None
node.Webhook = None
return node
@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"]
result = {}
for key, value in input_data.items():
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(
sensitive_key in key.lower() for sensitive_key in sensitive_keys
):
@ -495,7 +511,7 @@ async def get_graph(
version: int | None = None,
template: bool = False,
user_id: str | None = None,
hide_credentials: bool = False,
for_export: bool = False,
) -> GraphModel | None:
"""
Retrieves a graph from the DB.
@ -506,13 +522,13 @@ async def get_graph(
"""
where_clause: AgentGraphWhereInput = {
"id": graph_id,
"isTemplate": template,
}
if version is not None:
where_clause["version"] = version
elif not template:
where_clause["isActive"] = True
# TODO: Fix hack workaround to get adding store agents to work
if user_id is not None and not template:
where_clause["userId"] = user_id
@ -521,7 +537,7 @@ async def get_graph(
include=AGENT_GRAPH_INCLUDE,
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:

View File

@ -3,11 +3,12 @@ from typing import TYPE_CHECKING, AsyncGenerator, Optional
from prisma import Json
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.queue import AsyncRedisEventBus
from backend.integrations.providers import ProviderName
from backend.integrations.webhooks.utils import webhook_ingress_url
from .db import BaseDbModel
@ -31,6 +32,11 @@ class Webhook(BaseDbModel):
attached_nodes: Optional[list["NodeModel"]] = None
@computed_field
@property
def url(self) -> str:
return webhook_ingress_url(self.provider.value, self.id)
@staticmethod
def from_db(webhook: IntegrationWebhook):
from .graph import NodeModel
@ -84,8 +90,10 @@ async def get_webhook(webhook_id: str) -> 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."""
if not credentials_id:
raise ValueError("credentials_id must not be empty")
webhooks = await IntegrationWebhook.prisma().find_many(
where={"credentialsId": credentials_id},
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]
async def find_webhook(
async def find_webhook_by_credentials_and_props(
credentials_id: str, webhook_type: str, resource: str, events: list[str]
) -> Webhook | None:
"""⚠️ 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
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:
"""⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints."""
_updated_webhook = await IntegrationWebhook.prisma().update(

View File

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

View File

@ -798,10 +798,13 @@ class ExecutionManager(AppService):
# Extract webhook payload, and assign it to the input pin
webhook_payload_key = f"webhook_{node.webhook_id}_payload"
if (
block.block_type == BlockType.WEBHOOK
block.block_type in (BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL)
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, error = validate_exec(node, input_data)
@ -944,7 +947,8 @@ def synchronized(key: str, timeout: int = 60):
lock.acquire()
yield
finally:
lock.release()
if lock.locked():
lock.release()
def llprint(message: str):

View File

@ -99,6 +99,10 @@ class ExecutionScheduler(AppService):
def get_port(cls) -> int:
return config.execution_scheduler_port
@classmethod
def db_pool_size(cls) -> int:
return config.scheduler_db_pool_size
@property
@thread_cached
def execution_client(self) -> ExecutionManager:
@ -110,7 +114,11 @@ class ExecutionScheduler(AppService):
self.scheduler = BlockingScheduler(
jobstores={
"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),
)
}

View File

@ -92,7 +92,7 @@ class IntegrationCredentialsManager:
fresh_credentials = oauth_handler.refresh_tokens(credentials)
self.store.update_creds(user_id, fresh_credentials)
if _lock:
if _lock and _lock.locked():
_lock.release()
credentials = fresh_credentials
@ -144,7 +144,8 @@ class IntegrationCredentialsManager:
try:
yield
finally:
lock.release()
if lock.locked():
lock.release()
def release_all_locks(self):
"""Call this on process termination to ensure all locks are released"""

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import logging
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.model import CREDENTIALS_FIELD_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.model import Credentials
from .base import BaseWebhooksManager
from ._base import BaseWebhooksManager
logger = logging.getLogger(__name__)
@ -108,50 +108,79 @@ async def on_node_activate(
webhooks_manager = WEBHOOK_MANAGERS_BY_NAME[provider]()
try:
resource = block.webhook_config.resource_format.format(**node.input_default)
except KeyError:
resource = None
logger.debug(
f"Constructed resource string {resource} from input {node.input_default}"
)
if auto_setup_webhook := isinstance(block.webhook_config, BlockWebhookConfig):
try:
resource = block.webhook_config.resource_format.format(**node.input_default)
except KeyError:
resource = None
logger.debug(
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
has_everything_for_webhook = (
resource is not None
and CREDENTIALS_FIELD_NAME in node.input_default
and event_filter_input_name in node.input_default
and any(is_on for is_on in node.input_default[event_filter_input_name].values())
and (credentials_meta or not needs_credentials)
and (
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!")
if not credentials:
credentials_meta = node.input_default[CREDENTIALS_FIELD_NAME]
if credentials_meta and not credentials:
raise ValueError(
f"Cannot set up webhook for node #{node.id}: "
f"credentials #{credentials_meta['id']} not available"
)
# Shape of the event filter is enforced in Block.__init__
event_filter = cast(dict, node.input_default[event_filter_input_name])
events = [
block.webhook_config.event_format.format(event=event)
for event, enabled in event_filter.items()
if enabled is True
]
logger.debug(f"Webhook events to subscribe to: {', '.join(events)}")
if event_filter_input_name:
# Shape of the event filter is enforced in Block.__init__
event_filter = cast(dict, node.input_default[event_filter_input_name])
events = [
block.webhook_config.event_format.format(event=event)
for event, enabled in event_filter.items()
if enabled is True
]
logger.debug(f"Webhook events to subscribe to: {', '.join(events)}")
else:
events = []
# Find/make and attach a suitable webhook to the node
new_webhook = await webhooks_manager.get_suitable_webhook(
user_id,
credentials,
block.webhook_config.webhook_type,
resource,
events,
)
if auto_setup_webhook:
assert credentials is not None
new_webhook = await webhooks_manager.get_suitable_auto_webhook(
user_id,
credentials,
block.webhook_config.webhook_type,
resource,
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}")
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
@ -194,12 +223,16 @@ async def on_node_deactivate(
updated_node = await set_node_webhook(node.id, None)
# Prune and deregister the webhook if it is no longer used anywhere
logger.debug("Pruning and deregistering webhook if dangling")
webhook = node.webhook
if credentials:
logger.debug(f"Pruning webhook #{webhook.id} with credentials")
await webhooks_manager.prune_webhook_if_dangling(webhook.id, credentials)
else:
logger.debug(
f"Pruning{' and deregistering' if credentials else ''} "
f"webhook #{webhook.id}"
)
await webhooks_manager.prune_webhook_if_dangling(webhook.id, credentials)
if (
CREDENTIALS_FIELD_NAME in block.input_schema.model_fields
and not credentials
):
logger.warning(
f"Cannot deregister webhook #{webhook.id}: credentials "
f"#{webhook.credentials_id} not available "

View File

@ -6,7 +6,7 @@ from fastapi import Request
from backend.data import integrations
from backend.data.model import APIKeyCredentials, Credentials
from backend.integrations.providers import ProviderName
from backend.integrations.webhooks.base import BaseWebhooksManager
from backend.integrations.webhooks._base import BaseWebhooksManager
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.integrations import (
WebhookEvent,
get_all_webhooks,
get_all_webhooks_by_creds,
get_webhook,
publish_webhook_event,
wait_for_webhook_event,
@ -363,7 +363,7 @@ async def remove_all_webhooks_for_credentials(
Raises:
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 webhooks:
logger.error(

View File

@ -196,7 +196,7 @@ class DeleteGraphResponse(TypedDict):
@v1_router.get(path="/graphs", tags=["graphs"], dependencies=[Depends(auth_middleware)])
async def get_graphs(
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)
@ -213,9 +213,9 @@ async def get_graph(
user_id: Annotated[str, Depends(get_user_id)],
version: int | None = None,
hide_credentials: bool = False,
) -> graph_db.Graph:
) -> graph_db.GraphModel:
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:
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(
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)
if not graphs:
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(
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)
@ -256,7 +256,7 @@ async def do_create_graph(
# user_id doesn't have to be annotated like on other endpoints,
# because create_graph isn't used directly as an endpoint
user_id: str,
) -> graph_db.Graph:
) -> graph_db.GraphModel:
if create_graph.graph:
graph = graph_db.make_graph_model(create_graph.graph, user_id)
elif create_graph.template_id:
@ -317,7 +317,7 @@ async def update_graph(
graph_id: str,
graph: graph_db.Graph,
user_id: Annotated[str, Depends(get_user_id)],
) -> graph_db.Graph:
) -> graph_db.GraphModel:
# Sanity check
if graph.id and graph.id != graph_id:
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(
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)
@ -496,7 +496,9 @@ async def get_templates(
tags=["templates", "graphs"],
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)
if not graph:
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(
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)

View File

@ -4,14 +4,20 @@ import typing
import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware
import fastapi
import prisma
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.model
logger = logging.getLogger(__name__)
router = fastapi.APIRouter()
integration_creds_manager = (
backend.integrations.creds_manager.IntegrationCredentialsManager()
)
@router.get(
@ -63,10 +69,53 @@ async def add_agent_to_library(
HTTPException: If there is an error adding the agent to the library
"""
try:
await backend.server.v2.library.db.add_agent_to_library(
store_listing_version_id=store_listing_version_id, user_id=user_id
# Get the graph from the store listing
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)
except Exception:
logger.exception("Exception occurred whilst adding agent to library")
raise fastapi.HTTPException(

View File

@ -2,6 +2,7 @@ import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware
import fastapi
import fastapi.testclient
import pytest
import pytest_mock
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")
@pytest.mark.skip(reason="Mocker Not implemented")
def test_add_agent_to_library_success(mocker: pytest_mock.MockFixture):
mock_db_call = mocker.patch("backend.server.v2.library.db.add_agent_to_library")
mock_db_call.return_value = None
@ -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):
mock_db_call = mocker.patch("backend.server.v2.library.db.add_agent_to_library")
mock_db_call.side_effect = Exception("Test error")

View File

@ -31,7 +31,7 @@ async def get_store_agents(
sanitized_query = search_query.strip()
if not sanitized_query or len(sanitized_query) > 100: # Reasonable length limit
raise backend.server.v2.store.exceptions.DatabaseError(
"Invalid search query"
f"Invalid search query: len({len(sanitized_query)}) query: {search_query}"
)
# Escape special SQL characters
@ -173,7 +173,7 @@ async def get_store_creators(
where = {}
if featured:
where["isFeatured"] = featured
where["is_featured"] = featured
# Add search filter if provided, using parameterized queries
if search_query:
@ -449,6 +449,11 @@ async def create_store_submission(
)
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
agent = await prisma.models.AgentGraph.prisma().find_first(
where=prisma.types.AgentGraphWhereInput(
@ -578,22 +583,22 @@ async def get_user_profile(
if not profile:
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(
userId=user_id,
name="No Profile Data",
username=f"{random.choice(['happy', 'clever', 'swift', 'bright', 'wise'])}-{random.choice(['fox', 'wolf', 'bear', 'eagle', 'owl'])}_{random.randint(1000,9999)}",
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",
links=[],
avatarUrl="",
)
)
return backend.server.v2.store.model.ProfileDetails(
name="No Profile Data",
username="No Profile Data",
description="No Profile Data",
links=[],
avatar_url="",
name=new_profile.name,
username=new_profile.username,
description=new_profile.description,
links=new_profile.links,
avatar_url=new_profile.avatarUrl,
)
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.
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:
user_id: ID of the authenticated user
@ -630,27 +636,36 @@ async def update_or_create_profile(
Raises:
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:
# 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(
where={"userId": user_id}
)
# If no profile exists, create a new one
if not existing_profile:
logger.debug(f"Creating new profile for user {user_id}")
logger.debug(
f"No existing profile found. Creating new profile for user {user_id}"
)
# Create new profile since one doesn't exist
new_profile = await prisma.models.Profile.prisma().create(
data={
"userId": user_id,
"name": profile.name,
"username": profile.username,
"username": username,
"description": profile.description,
"links": profile.links,
"links": profile.links or [],
"avatarUrl": profile.avatar_url,
"isFeatured": False,
}
)
@ -666,16 +681,23 @@ async def update_or_create_profile(
)
else:
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
updated_profile = await prisma.models.Profile.prisma().update(
where={"id": existing_profile.id},
data=prisma.types.ProfileUpdateInput(
name=profile.name,
username=profile.username,
description=profile.description,
links=profile.links,
avatarUrl=profile.avatar_url,
),
data=prisma.types.ProfileUpdateInput(**update_data),
)
if updated_profile is None:
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_name=agent.name or "",
last_edited=agent.updatedAt or agent.createdAt,
description=agent.description or "",
)
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
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
try:
@ -84,6 +122,9 @@ async def upload_media(user_id: str, file: fastapi.UploadFile) -> str:
try:
# Validate file type
content_type = file.content_type
if content_type is None:
content_type = "image/jpeg"
if (
content_type not in ALLOWED_IMAGE_TYPES
and content_type not in ALLOWED_VIDEO_TYPES
@ -119,7 +160,10 @@ async def upload_media(user_id: str, file: fastapi.UploadFile) -> str:
# Generate unique filename
filename = file.filename or ""
file_ext = os.path.splitext(filename)[1].lower()
unique_filename = f"{uuid.uuid4()}{file_ext}"
if use_file_name:
unique_filename = filename
else:
unique_filename = f"{uuid.uuid4()}{file_ext}"
# Construct storage path
media_type = "images" if content_type in ALLOWED_IMAGE_TYPES else "videos"

View File

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

View File

@ -1,12 +1,15 @@
import logging
import typing
import urllib.parse
import autogpt_libs.auth.depends
import autogpt_libs.auth.middleware
import fastapi
import fastapi.responses
import backend.data.graph
import backend.server.v2.store.db
import backend.server.v2.store.image_gen
import backend.server.v2.store.media
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(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
]
) -> backend.server.v2.store.model.ProfileDetails:
):
"""
Get the profile details for the authenticated user.
"""
@ -34,20 +41,24 @@ async def get_profile(
return profile
except Exception:
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(
"/profile",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.CreatorDetails,
)
async def update_or_create_profile(
profile: backend.server.v2.store.model.Profile,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> backend.server.v2.store.model.CreatorDetails:
):
"""
Update the store profile for the authenticated user.
@ -68,7 +79,10 @@ async def update_or_create_profile(
return updated_profile
except Exception:
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(
featured: bool = False,
creator: str | None = None,
@ -85,7 +103,7 @@ async def get_agents(
category: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreAgentsResponse:
):
"""
Get a paginated list of agents from the store with optional filtering and sorting.
@ -135,32 +153,46 @@ async def get_agents(
return agents
except Exception:
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"])
async def get_agent(
username: str, agent_name: str
) -> backend.server.v2.store.model.StoreAgentDetails:
@router.get(
"/agents/{username}/{agent_name}",
tags=["store", "public"],
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
It returns the store listing agents details.
"""
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(
username=username, agent_name=agent_name
)
return agent
except Exception:
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(
"/agents/{username}/{agent_name}/review",
tags=["store"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.StoreReview,
)
async def create_review(
username: str,
@ -169,7 +201,7 @@ async def create_review(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> backend.server.v2.store.model.StoreReview:
):
"""
Create a review for a store agent.
@ -183,6 +215,8 @@ async def create_review(
The created review
"""
try:
username = urllib.parse.unquote(username).lower()
agent_name = urllib.parse.unquote(agent_name)
# Create the review
created_review = await backend.server.v2.store.db.create_store_review(
user_id=user_id,
@ -194,7 +228,10 @@ async def create_review(
return created_review
except Exception:
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(
featured: bool = False,
search_query: str | None = None,
sorted_by: str | None = None,
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.CreatorsResponse:
):
"""
This is needed for:
- Home Page Featured Creators
@ -243,23 +284,38 @@ async def get_creators(
return creators
except Exception:
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"])
async def get_creator(username: str) -> backend.server.v2.store.model.CreatorDetails:
@router.get(
"/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
- Creator Details Page
"""
try:
username = urllib.parse.unquote(username).lower()
creator = await backend.server.v2.store.db.get_store_creator_details(
username=username
username=username.lower()
)
return creator
except Exception:
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",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.MyAgentsResponse,
)
async def get_my_agents(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
]
) -> backend.server.v2.store.model.MyAgentsResponse:
):
try:
agents = await backend.server.v2.store.db.get_my_agents(user_id)
return agents
except Exception:
logger.exception("Exception occurred whilst getting my agents")
raise
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving the my agents"},
)
@router.delete(
"/submissions/{submission_id}",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=bool,
)
async def delete_submission(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
submission_id: str,
) -> bool:
):
"""
Delete a store listing submission.
@ -312,13 +373,17 @@ async def delete_submission(
return result
except Exception:
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(
"/submissions",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.StoreSubmissionsResponse,
)
async def get_submissions(
user_id: typing.Annotated[
@ -326,7 +391,7 @@ async def get_submissions(
],
page: int = 1,
page_size: int = 20,
) -> backend.server.v2.store.model.StoreSubmissionsResponse:
):
"""
Get a paginated list of store submissions for the authenticated user.
@ -359,20 +424,26 @@ async def get_submissions(
return listings
except Exception:
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(
"/submissions",
tags=["store", "private"],
dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)],
response_model=backend.server.v2.store.model.StoreSubmission,
)
async def create_submission(
submission_request: backend.server.v2.store.model.StoreSubmissionRequest,
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> backend.server.v2.store.model.StoreSubmission:
):
"""
Create a new store listing submission.
@ -402,7 +473,10 @@ async def create_submission(
return submission
except Exception:
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(
@ -415,7 +489,7 @@ async def upload_submission_media(
user_id: typing.Annotated[
str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)
],
) -> str:
):
"""
Upload media (images/videos) for a store listing submission.
@ -434,8 +508,70 @@ async def upload_submission_media(
user_id=user_id, file=file
)
return media_url
except Exception as e:
except Exception:
logger.exception("Exception occurred whilst uploading submission media")
raise fastapi.HTTPException(
status_code=500, detail=f"Failed to upload media file: {str(e)}"
return fastapi.responses.JSONResponse(
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
BLOCKED_IP_NETWORKS = [
# --8<-- [start:BLOCKED_IP_NETWORKS]
# IPv4 Ranges
ipaddress.ip_network("0.0.0.0/8"), # "This" Network
ipaddress.ip_network("10.0.0.0/8"), # Private-Use
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("224.0.0.0/4"), # Multicast
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]
]
@ -27,18 +33,6 @@ ALLOWED_SCHEMES = ["http", "https"]
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:
"""
Checks if the IP address is in a blocked network.
@ -49,11 +43,16 @@ def _is_ip_blocked(ip: str) -> bool:
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
or untrusted IP address, unless whitelisted.
Validates the URL to prevent SSRF attacks by ensuring it does not point
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)
# Check scheme
if parsed.scheme not in ALLOWED_SCHEMES:
@ -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."
)
# Validate and IDNA encode the hostname
# Validate and IDNA encode hostname
if not parsed.hostname:
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):
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)
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:
return url
@ -92,11 +91,12 @@ def validate_url(url: str, trusted_origins: list[str]) -> str:
if not ip_addresses:
raise ValueError(f"No IP addresses found for {ascii_hostname}")
# Check if any resolved IP address falls into blocked ranges
for ip in ip_addresses:
if _is_ip_blocked(ip):
# Block any IP address that belongs to a blocked range
for ip_str in ip_addresses:
if _is_ip_blocked(ip_str):
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
@ -104,7 +104,9 @@ def validate_url(url: str, trusted_origins: list[str]) -> str:
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__(
@ -128,13 +130,16 @@ class Requests:
def request(
self, method, url, headers=None, allow_redirects=False, *args, **kwargs
) -> req.Response:
# Merge any extra headers
if self.extra_headers is not None:
headers = {**(headers or {}), **self.extra_headers}
# Validate the URL (with optional extra validator)
url = validate_url(url, self.trusted_origins)
if self.extra_url_validator is not None:
url = self.extra_url_validator(url)
# Perform the request
response = req.request(
method,
url,

View File

@ -153,6 +153,11 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
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")
@classmethod
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]]
name = "aio-pika"
@ -579,21 +579,6 @@ files = [
{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]]
name = "cryptography"
version = "43.0.3"
@ -3014,17 +2999,6 @@ files = [
{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]]
name = "pyyaml"
version = "6.0.2"
@ -4339,4 +4313,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
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"
autogpt-libs = { path = "../autogpt_libs", develop = true }
click = "^8.1.7"
croniter = "^5.0.1"
discord-py = "^2.4.0"
e2b-code-interpreter = "^1.0.1"
fastapi = "^0.115.5"

View File

@ -90,7 +90,12 @@ async def test_get_input_schema(server: SpinTestServer):
Node(
id="node_0_a",
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"},
),
Node(
@ -138,8 +143,8 @@ async def test_get_input_schema(server: SpinTestServer):
)
class ExpectedInputSchema(BlockSchema):
in_key_a: Any = SchemaField(title="Key A", default="A", advanced=False)
in_key_b: Any = SchemaField(title="in_key_b", advanced=True)
in_key_a: Any = SchemaField(title="Key A", default="A", advanced=True)
in_key_b: Any = SchemaField(title="in_key_b", advanced=False)
class ExpectedOutputSchema(BlockSchema):
out_key: Any = SchemaField(

View File

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

View File

@ -37,7 +37,7 @@ export default async function RootLayout({
<Navbar
links={[
{
name: "Agent Store",
name: "Marketplace",
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 />
</div>
<Toaster />

View File

@ -5,11 +5,7 @@ import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import BackendAPI from "@/lib/autogpt-server-api";
const loginFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
});
import { loginFormSchema, LoginProvider } from "@/types/auth";
export async function logout() {
return await Sentry.withServerActionInstrumentation(
@ -25,7 +21,7 @@ export async function logout() {
const { error } = await supabase.auth.signOut();
if (error) {
console.log("Error logging out", error);
console.error("Error logging out", error);
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
const { data, error } = await supabase.auth.signInWithPassword(values);
await api.createUser();
if (error) {
console.log("Error logging in", error);
if (error.status == 400) {
// Hence User is not present
redirect("/login");
}
console.error("Error logging in", error);
return error.message;
}
await api.createUser();
if (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>) {
"use server";
export async function providerLogin(provider: LoginProvider) {
return await Sentry.withServerActionInstrumentation(
"signup",
"providerLogin",
{},
async () => {
const supabase = getServerSupabase();
const api = new BackendAPI();
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);
const { error } = await supabase!.auth.signInWithOAuth({
provider: provider,
options: {
redirectTo:
process.env.AUTH_CALLBACK_URL ??
`http://localhost:3000/auth/callback`,
},
});
if (error) {
console.log("Error signing up", error);
if (error.message.includes("P0001")) {
return "Please join our waitlist for your turn: https://agpt.co/waitlist";
}
if (error.code?.includes("user_already_exists")) {
redirect("/login");
}
console.error("Error logging in", error);
return error.message;
}
if (data.session) {
await supabase.auth.setSession(data.session);
}
console.log("Signed up");
revalidatePath("/", "layout");
redirect("/store/profile");
await api.createUser();
console.log("Logged in");
},
);
}

View File

@ -1,10 +1,8 @@
"use client";
import { login, signup } from "./actions";
import { Button } from "@/components/ui/button";
import { login, providerLogin } from "./actions";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@ -14,40 +12,69 @@ import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { PasswordInput } from "@/components/PasswordInput";
import { FaGoogle, FaGithub, FaDiscord, FaSpinner } from "react-icons/fa";
import { useState } from "react";
import { 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 { useBackendAPI } from "@/lib/autogpt-server-api/context";
const loginFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
agreeToTerms: z.boolean().refine((value) => value === true, {
message: "You must agree to the Terms of Use and Privacy Policy",
}),
});
import {
AuthCard,
AuthHeader,
AuthButton,
AuthFeedback,
AuthBottomText,
PasswordInput,
} from "@/components/auth";
import { loginFormSchema } from "@/types/auth";
export default function LoginPage() {
const { supabase, user, isUserLoading } = useSupabase();
const [feedback, setFeedback] = useState<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const api = useBackendAPI();
const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
email: "",
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) {
console.debug("User exists, redirecting to /");
router.push("/");
@ -65,179 +92,60 @@ 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 (
<div className="flex h-[80vh] items-center justify-center">
<div className="w-full max-w-md space-y-6 rounded-lg p-8 shadow-md">
<h1 className="text-lg font-medium">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}
<AuthCard>
<AuthHeader>Login to your account</AuthHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onLogin)}>
<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 className="flex w-full items-center justify-between">
<span>Password</span>
<Link
href="/reset_password"
className="text-sm font-normal leading-normal text-black underline"
>
Forgot your password?
</Link>
</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<AuthButton
onClick={() => onLogin(form.getValues())}
isLoading={isLoading}
type="submit"
>
<FaGoogle className="mr-2 h-4 w-4" />
Sign in with Google
</Button>
<Button
className="w-full"
onClick={() => handleSignInWithProvider("github")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaGithub className="mr-2 h-4 w-4" />
Sign in with GitHub
</Button>
<Button
className="w-full"
onClick={() => handleSignInWithProvider("discord")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaDiscord className="mr-2 h-4 w-4" />
Sign in with Discord
</Button>
</div> */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onLogin)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="user@email.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder="password" {...field} />
</FormControl>
<FormDescription>
Password needs to be at least 6 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
I agree to the{" "}
<Link
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
className="underline"
>
Terms of Use
</Link>{" "}
and{" "}
<Link
href="https://www.notion.so/auto-gpt/Privacy-Policy-ab11c9c20dbd4de1a15dcffe84d77984"
className="underline"
>
Privacy Policy
</Link>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<div className="mb-6 mt-8 flex w-full space-x-4">
<Button
className="flex w-full justify-center"
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?
</Link>
</div>
</div>
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";
import { Button } from "@/components/ui/button";
import {
AuthCard,
AuthHeader,
AuthButton,
AuthFeedback,
PasswordInput,
} from "@/components/auth";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@ -10,54 +17,87 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import useSupabase from "@/hooks/useSupabase";
import { sendEmailFormSchema, changePasswordFormSchema } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { FaSpinner } from "react-icons/fa";
import { z } from "zod";
const emailFormSchema = z.object({
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"],
});
import { changePassword, sendResetEmail } from "./actions";
import Spinner from "@/components/Spinner";
export default function ResetPasswordPage() {
const { supabase, user, isUserLoading } = useSupabase();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const [isError, setIsError] = useState(false);
const [disabled, setDisabled] = useState(false);
const emailForm = useForm<z.infer<typeof emailFormSchema>>({
resolver: zodResolver(emailFormSchema),
const sendEmailForm = useForm<z.infer<typeof sendEmailFormSchema>>({
resolver: zodResolver(sendEmailFormSchema),
defaultValues: {
email: "",
},
});
const resetPasswordForm = useForm<z.infer<typeof resetPasswordFormSchema>>({
resolver: zodResolver(resetPasswordFormSchema),
const changePasswordForm = useForm<z.infer<typeof changePasswordFormSchema>>({
resolver: zodResolver(changePasswordFormSchema),
defaultValues: {
password: "",
confirmPassword: "",
},
});
const onSendEmail = useCallback(
async (data: z.infer<typeof sendEmailFormSchema>) => {
setIsLoading(true);
setFeedback(null);
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 (
<div className="flex h-[80vh] items-center justify-center">
<FaSpinner className="mr-2 h-16 w-16 animate-spin" />
</div>
);
return <Spinner />;
}
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 (
<div className="flex h-full flex-col items-center justify-center">
<div className="w-full max-w-md">
<h1 className="text-center text-3xl font-bold">Reset Password</h1>
{user ? (
<form
onSubmit={resetPasswordForm.handleSubmit(onResetPassword)}
className="mt-6 space-y-6"
>
<Form {...resetPasswordForm}>
<FormField
control={resetPasswordForm.control}
name="password"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={resetPasswordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="mb">
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isLoading}
onClick={() => onResetPassword(resetPasswordForm.getValues())}
>
{isLoading ? <FaSpinner className="mr-2 animate-spin" /> : null}
Reset Password
</Button>
</Form>
</form>
) : (
<form
onSubmit={emailForm.handleSubmit(onSendEmail)}
className="mt-6 space-y-6"
>
<Form {...emailForm}>
<FormField
control={emailForm.control}
name="email"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="user@email.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isLoading}
onClick={() => onSendEmail(emailForm.getValues())}
>
{isLoading ? <FaSpinner className="mr-2 animate-spin" /> : null}
Send Reset Email
</Button>
{feedback ? (
<div className="text-center text-sm text-red-500">
{feedback}
</div>
) : null}
</Form>
</form>
)}
</div>
</div>
<AuthCard>
<AuthHeader>Reset Password</AuthHeader>
{user ? (
<form onSubmit={changePasswordForm.handleSubmit(onChangePassword)}>
<Form {...changePasswordForm}>
<FormField
control={changePasswordForm.control}
name="password"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={changePasswordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="mb-6">
<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={() => onChangePassword(changePasswordForm.getValues())}
isLoading={isLoading}
type="submit"
>
Update password
</AuthButton>
<AuthFeedback message={feedback} isError={isError} />
</Form>
</form>
) : (
<form onSubmit={sendEmailForm.handleSubmit(onSendEmail)}>
<Form {...sendEmailForm}>
<FormField
control={sendEmailForm.control}
name="email"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="m@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<AuthButton
onClick={() => onSendEmail(sendEmailForm.getValues())}
isLoading={isLoading}
disabled={disabled}
type="submit"
>
Send reset email
</AuthButton>
<AuthFeedback message={feedback} isError={isError} />
</Form>
</form>
)}
</AuthCard>
);
}

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 };
}) {
const creator_lower = params.creator.toLowerCase();
const api = new BackendAPI();
const agent = await api.getStoreAgent(params.creator, params.slug);
const otherAgents = await api.getStoreAgents({ creator: params.creator });
const agent = await api.getStoreAgent(creator_lower, params.slug);
const otherAgents = await api.getStoreAgents({ creator: creator_lower });
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 = [
{ 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: "#" },
];
return (
<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} />
<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}
/>
</div>
<AgentImages images={agent.agent_image} />
<AgentImages
images={
agent.agent_video
? [agent.agent_video, ...agent.agent_image]
: agent.agent_image
}
/>
</div>
<Separator className="my-6" />
<Separator className="mb-[25px] mt-6" />
<AgentsSection
agents={otherAgents.agents}
sectionTitle={`Other agents by ${agent.creator}`}
/>
<Separator className="my-6" />
<Separator className="mb-[25px] mt-6" />
<AgentsSection
agents={similarAgents.agents}
sectionTitle="Similar agents"

View File

@ -15,7 +15,7 @@ export async function generateMetadata({
params: { creator: string };
}): Promise<Metadata> {
const api = new BackendAPI();
const creator = await api.getStoreCreator(params.creator);
const creator = await api.getStoreCreator(params.creator.toLowerCase());
return {
title: `${creator.name} - AutoGPT Store`,
@ -44,7 +44,7 @@ export default async function Page({
return (
<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={[
{ name: "Store", link: "/store" },
@ -64,9 +64,16 @@ export default async function Page({
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-4 sm:gap-6 md:gap-8">
<div className="font-neue text-2xl font-normal leading-normal text-neutral-900 sm:text-3xl md:text-[35px] md:leading-[45px]">
<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}
</div>
<CreatorLinks links={creator.links} />
</div>
</div>

View File

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

View File

@ -6,7 +6,7 @@ import React, {
useContext,
useMemo,
} 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 "./customnode.css";
import InputModalComponent from "./InputModalComponent";
@ -16,6 +16,7 @@ import {
BlockIOSubSchema,
BlockIOStringSubSchema,
Category,
Node,
NodeExecutionResult,
BlockUIType,
BlockCost,
@ -71,7 +72,7 @@ export type CustomNodeData = {
outputSchema: BlockIORootSchema;
hardcodedValues: { [key: string]: any };
connections: ConnectionData;
webhookId?: string;
webhook?: Node["webhook"];
isOutputOpen: boolean;
status?: NodeExecutionResult["status"];
/** executionResults contains outputs across multiple executions
@ -87,7 +88,7 @@ export type CustomNodeData = {
uiType: BlockUIType;
};
export type CustomNode = Node<CustomNodeData, "custom">;
export type CustomNode = XYNode<CustomNodeData, "custom">;
export function CustomNode({
data,
@ -237,7 +238,11 @@ export function CustomNode({
const isHidden = propSchema.hidden;
const isConnectable =
// 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
propKey !== "credentials" &&
// For OUTPUT blocks, only show the 'value' (hides 'name') input connection handle
@ -549,22 +554,25 @@ export function CustomNode({
>(null);
useEffect(() => {
if (data.uiType != BlockUIType.WEBHOOK) return;
if (!data.webhookId) {
if (
![BlockUIType.WEBHOOK, BlockUIType.WEBHOOK_MANUAL].includes(data.uiType)
)
return;
if (!data.webhook) {
setWebhookStatus("none");
return;
}
setWebhookStatus("pending");
api
.pingWebhook(data.webhookId)
.pingWebhook(data.webhook.id)
.then((pinged) => setWebhookStatus(pinged ? "works" : "exists"))
.catch((error: Error) =>
error.message.includes("ping timed out")
? setWebhookStatus("broken")
: setWebhookStatus("none"),
);
}, [data.uiType, data.webhookId, api, setWebhookStatus]);
}, [data.uiType, data.webhook, api, setWebhookStatus]);
const webhookStatusDot = useMemo(
() =>
@ -718,14 +726,38 @@ export function CustomNode({
</div>
{/* Body */}
<div className="ml-5 mt-6 rounded-b-xl">
<div className="mx-5 my-6 rounded-b-xl">
{/* Input Handles */}
{data.uiType !== BlockUIType.NOTE ? (
<div
className="flex w-fit items-start justify-between"
data-id="input-handles"
>
<div data-id="input-handles">
<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 &&
generateInputHandles(data.inputSchema, data.uiType)}
</div>
@ -746,7 +778,6 @@ export function CustomNode({
<Switch
onCheckedChange={toggleAdvancedSettings}
checked={isAdvancedOpen}
className="mr-5"
/>
</div>
</>
@ -755,7 +786,7 @@ export function CustomNode({
{data.uiType !== BlockUIType.NOTE && (
<>
<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">
{data.outputSchema &&
generateOutputHandles(data.outputSchema, data.uiType)}

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@ export const AgentImages: React.FC<AgentImagesProps> = ({ images }) => {
);
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]">
{images.map((image, index) => (
<AgentImageItem

View File

@ -5,6 +5,7 @@ import { IconPlay, StarRatingIcons } from "@/components/ui/icons";
import { Separator } from "@/components/ui/separator";
import BackendAPI from "@/lib/autogpt-server-api";
import { useRouter } from "next/navigation";
import Link from "next/link";
interface AgentInfoProps {
name: 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">
by
</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}
</div>
</Link>
</div>
{/* Short Description */}
@ -67,7 +71,7 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
</div>
{/* Run Agent Button */}
<div className="mb-4 w-full lg:mb-6">
<div className="mb-4 w-full lg:mb-[60px]">
<button
onClick={handleAddToLibrary}
className="inline-flex w-full items-center justify-center gap-2 rounded-[38px] bg-violet-600 px-4 py-3 transition-colors hover:bg-violet-700 sm:w-auto sm:gap-2.5 sm:px-5 sm:py-3.5 lg:px-6 lg:py-4"
@ -80,7 +84,7 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
</div>
{/* 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">
<span className="font-geist whitespace-nowrap text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
{rating.toFixed(1)}
@ -93,28 +97,28 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
</div>
{/* Separator */}
<Separator className="mb-4 lg:mb-6" />
<Separator className="mb-4 lg:mb-[44px]" />
{/* Description Section */}
<div className="mb-4 w-full lg:mb-6">
<div className="mb-1.5 text-xs font-medium text-neutral-800 dark:text-neutral-200 sm:mb-2 sm:text-sm">
<div className="mb-4 w-full lg:mb-[36px]">
<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
</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}
</div>
</div>
{/* Categories */}
<div className="mb-4 flex w-full flex-col gap-1.5 sm:gap-2 lg:mb-6">
<div className="text-xs font-medium text-neutral-800 dark:text-neutral-200 sm:text-sm">
<div className="mb-4 flex w-full flex-col gap-1.5 sm:gap-2 lg:mb-[36px]">
<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
</div>
<div className="flex flex-wrap gap-1.5 sm:gap-2">
{categories.map((category, index) => (
<div
key={index}
className="whitespace-nowrap rounded-full border border-neutral-200 bg-white px-2 py-0.5 text-xs text-neutral-800 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 sm:px-3 sm:py-1 sm:text-sm"
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}
</div>
@ -124,10 +128,10 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
{/* Version History */}
<div className="flex w-full flex-col gap-0.5 sm:gap-1">
<div className="text-xs font-medium text-neutral-800 dark:text-neutral-200 sm:text-sm">
<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
</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}
</div>
<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 { IconStarFilled, IconMore, IconEdit } from "@/components/ui/icons";
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 { StoreSubmissionRequest } from "@/lib/autogpt-server-api/types";
@ -139,30 +139,30 @@ export const AgentTableRow: React.FC<AgentTableRowProps> = ({
{/* Actions - Three dots menu */}
<div className="flex justify-end">
<ContextMenu.Root>
<ContextMenu.Trigger>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<button className="rounded-full p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700">
<IconMore className="h-5 w-5 text-neutral-800 dark:text-neutral-200" />
</button>
</ContextMenu.Trigger>
<ContextMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800">
<ContextMenu.Item
</DropdownMenu.Trigger>
<DropdownMenu.Content className="z-10 rounded-xl border bg-white p-1 shadow-md dark:bg-gray-800">
<DropdownMenu.Item
onSelect={handleEdit}
className="flex cursor-pointer items-center rounded-md px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<IconEdit className="mr-2 h-5 w-5 dark:text-gray-100" />
<span className="dark:text-gray-100">Edit</span>
</ContextMenu.Item>
<ContextMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" />
<ContextMenu.Item
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-gray-300 dark:bg-gray-600" />
<DropdownMenu.Item
onSelect={handleDelete}
className="flex cursor-pointer items-center rounded-md px-3 py-2 text-red-500 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<TrashIcon className="mr-2 h-5 w-5 text-red-500 dark:text-red-400" />
<span className="dark:text-red-400">Delete</span>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</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" />
{/* 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}
</h2>
{/* Content Container */}
<div className="absolute left-1/2 top-1/2 w-full max-w-[900px] -translate-x-1/2 -translate-y-1/2 px-4 pt-16 text-center md:px-6 md:pt-10 lg:px-0">
<h2 className="font-poppins mb-6 text-3xl font-semibold leading-tight text-neutral-950 dark:text-neutral-50 md:mb-8 md:text-4xl md:leading-[1.2] lg:mb-12 lg:text-5xl lg:leading-[54px]">
<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 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
<br />
<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">
<IconRightArrow className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
</button> */}
<div className="flex h-auto min-h-[4.375rem] flex-wrap items-center justify-start gap-4 rounded-[5rem] bg-white dark:bg-neutral-900">
<div className="flex h-auto flex-wrap items-center justify-start gap-4 rounded-[5rem] bg-white dark:bg-transparent">
{items.map((item, index) => (
<React.Fragment key={index}>
<Link href={item.link}>

View File

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

View File

@ -8,7 +8,7 @@ interface FilterChipsProps {
onFilterChange?: (selectedFilters: string[]) => void;
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> = ({
badges,
onFilterChange,

View File

@ -6,11 +6,13 @@ import { MobileNavBar } from "./MobileNavBar";
import { Button } from "./Button";
import CreditsCard from "./CreditsCard";
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
import { ThemeToggle } from "./ThemeToggle";
import { NavbarLink } from "./NavbarLink";
import getServerUser from "@/lib/supabase/getServerUser";
import BackendAPI from "@/lib/autogpt-server-api";
// Disable theme toggle for now
// import { ThemeToggle } from "./ThemeToggle";
interface NavLink {
name: string;
href: string;
@ -55,7 +57,7 @@ export const Navbar = async ({ links, menuItemGroups }: NavbarProps) => {
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="relative h-10 w-[88.87px]">
<IconAutoGPTLogo className="h-full w-full" />
@ -93,7 +95,7 @@ export const Navbar = async ({ links, menuItemGroups }: NavbarProps) => {
</Button>
</Link>
)}
<ThemeToggle />
{/* <ThemeToggle /> */}
</div>
</nav>
{/* Mobile Navbar - Adjust positioning */}
@ -107,7 +109,7 @@ export const Navbar = async ({ links, menuItemGroups }: NavbarProps) => {
groupName: "Navigation",
items: links.map((link) => ({
icon:
link.name === "Agent Store"
link.name === "Marketplace"
? IconType.Marketplace
: link.name === "Library"
? IconType.Library

View File

@ -20,34 +20,34 @@ export const NavbarLink = ({ name, href }: NavbarLinkProps) => {
const activeLink = "/" + (parts.length > 2 ? parts[2] : parts[1]);
return (
<div
className={`h-[48px] px-5 py-4 ${
activeLink === href
? "rounded-2xl bg-neutral-800 dark:bg-neutral-200"
: ""
} flex items-center justify-start gap-3`}
>
{href === "/store" && (
<IconShoppingCart
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/>
)}
{href === "/build" && (
<IconBoxes
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/>
)}
{href === "/monitor" && (
<IconLaptop
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/>
)}
{href === "/monitoring" && (
<IconLibrary
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/>
)}
<Link href={href}>
<Link href={href} data-testid={`navbar-link-${name.toLowerCase()}`}>
<div
className={`h-[48px] px-5 py-4 ${
activeLink === href
? "rounded-2xl bg-neutral-800 dark:bg-neutral-200"
: ""
} flex items-center justify-start gap-3`}
>
{href === "/store" && (
<IconShoppingCart
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/>
)}
{href === "/build" && (
<IconBoxes
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/>
)}
{href === "/monitor" && (
<IconLaptop
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/>
)}
{href === "/monitoring" && (
<IconLibrary
className={`h-6 w-6 ${activeLink === href ? "text-white dark:text-black" : ""}`}
/>
)}
<div
className={`font-poppins text-[20px] font-medium leading-[28px] ${
activeLink === href
@ -57,7 +57,7 @@ export const NavbarLink = ({ name, href }: NavbarLinkProps) => {
>
{name}
</div>
</Link>
</div>
</div>
</Link>
);
};

View File

@ -26,7 +26,7 @@ export const ProfileInfoForm = ({ profile }: { profile: CreatorDetails }) => {
name: profileData.name,
username: profileData.username,
description: profileData.description,
links: profileData.links,
links: profileData.links.filter((link) => link), // Filter out empty links
avatar_url: profileData.avatar_url,
};
@ -225,11 +225,11 @@ export const ProfileInfoForm = ({ profile }: { profile: CreatorDetails }) => {
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"
onChange={(e) => {
const newLinks = [...profileData.links];
newLinks[linkNum - 1] = e.target.value;
const newProfileData = {
...profileData,
links: profileData.links.map((link, index) =>
index === linkNum - 1 ? e.target.value : link,
),
links: newLinks,
};
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">
{agents.map((agent) => (
<div
key={agent.name}
key={agent.id}
className={`cursor-pointer overflow-hidden rounded-2xl transition-all ${
selectedAgent === agent.name
selectedAgentId === agent.id
? "shadow-lg ring-4 ring-violet-600"
: "hover:shadow-md"
}`}
@ -124,7 +124,7 @@ export const PublishAgentSelect: React.FC<PublishAgentSelectProps> = ({
}}
tabIndex={0}
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">
<Image

View File

@ -19,6 +19,7 @@ interface PublishAgentInfoProps {
) => void;
onClose: () => void;
initialData?: {
agent_id: string;
title: string;
subheader: string;
slug: string;
@ -36,6 +37,7 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
onClose,
initialData,
}) => {
const [agentId, setAgentId] = React.useState<string | null>(null);
const [images, setImages] = React.useState<string[]>(
initialData?.additionalImages
? [initialData.thumbnailSrc, ...initialData.additionalImages]
@ -59,11 +61,32 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
);
const [slug, setSlug] = React.useState(initialData?.slug || "");
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 newImages = [...images];
newImages.splice(indexToRemove, 1);
setImages(newImages);
setImagesWithValidation(newImages);
if (newImages[indexToRemove] === selectedImage) {
setSelectedImage(newImages[0] || null);
}
@ -75,6 +98,8 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
};
const handleAddImage = async () => {
if (images.length >= 5) return;
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
@ -102,11 +127,7 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
"$1",
);
setImages((prev) => {
const newImages = [...prev, imageUrl];
console.log("Added image. Images now:", newImages);
return newImages;
});
setImagesWithValidation([...images, imageUrl]);
if (!selectedImage) {
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>) => {
e.preventDefault();
onSubmit(title, subheader, slug, description, images, youtubeLink, [
@ -271,19 +313,21 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
</button>
</div>
))}
<Button
onClick={handleAddImage}
variant="ghost"
className="flex h-[70px] w-[100px] flex-col items-center justify-center rounded-md bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-700 dark:hover:bg-neutral-600"
>
<IconPlus
size="lg"
className="text-neutral-600 dark:text-neutral-300"
/>
<span className="mt-1 font-['Geist'] text-xs font-normal text-neutral-600 dark:text-neutral-300">
Add image
</span>
</Button>
{images.length < 5 && (
<Button
onClick={handleAddImage}
variant="ghost"
className="flex h-[70px] w-[100px] flex-col items-center justify-center rounded-md bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-700 dark:hover:bg-neutral-600"
>
<IconPlus
size="lg"
className="text-neutral-600 dark:text-neutral-300"
/>
<span className="mt-1 font-['Geist'] text-xs font-normal text-neutral-600 dark:text-neutral-300">
Add image
</span>
</Button>
)}
</>
)}
</div>
@ -300,9 +344,17 @@ export const PublishAgentInfo: React.FC<PublishAgentInfoProps> = ({
<Button
variant="default"
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>
</div>
</div>

View File

@ -66,7 +66,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
</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"
>
<IconIntegrations className="h-6 w-6" />
@ -121,7 +121,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
</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"
>
<IconIntegrations className="h-6 w-6" />

View File

@ -32,7 +32,7 @@ export const StoreCard: React.FC<StoreCardProps> = ({
return (
<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}
data-testid="store-card"
role="button"

View File

@ -34,17 +34,19 @@ export const AgentsSection: React.FC<AgentsSectionProps> = ({
}) => {
const router = useRouter();
// Take only the first 9 agents
const displayedAgents = allAgents.slice(0, 9);
// TODO: Update this when we have pagination
const displayedAgents = allAgents;
const handleCardClick = (creator: string, slug: string) => {
router.push(`/store/agent/${creator}/${slug}`);
router.push(
`/store/agent/${encodeURIComponent(creator)}/${encodeURIComponent(slug)}`,
);
};
return (
<div className="flex flex-col items-center justify-center py-4 lg:py-8">
<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}
</div>
{!displayedAgents || displayedAgents.length === 0 ? (

View File

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

View File

@ -40,7 +40,9 @@ export const FeaturedSection: React.FC<FeaturedSectionProps> = ({
const router = useRouter();
const handleCardClick = (creator: string, slug: string) => {
router.push(`/store/agent/${creator}/${slug}`);
router.push(
`/store/agent/${encodeURIComponent(creator)}/${encodeURIComponent(slug)}`,
);
};
const handlePrevSlide = useCallback(() => {
@ -113,7 +115,7 @@ export const FeaturedSection: React.FC<FeaturedSectionProps> = ({
/>
))}
</div>
<div className="flex items-center gap-3">
<div className="mb-[60px] flex items-center gap-3">
<button
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"

View File

@ -17,6 +17,7 @@ import {
} from "@/lib/autogpt-server-api";
import { useRouter } from "next/navigation";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useToast } from "@/components/ui/use-toast";
interface PublishAgentPopoutProps {
trigger?: React.ReactNode;
openPopout?: boolean;
@ -44,6 +45,17 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
);
const [myAgents, setMyAgents] = React.useState<MyAgentsResponse | 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] =
React.useState<StoreSubmissionRequest>(submissionData);
const [selectedAgentId, setSelectedAgentId] = React.useState<string | null>(
@ -58,6 +70,8 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
const router = useRouter();
const api = useBackendAPI();
const { toast } = useToast();
React.useEffect(() => {
console.log("PublishAgentPopout Effect");
setOpen(openPopout);
@ -102,6 +116,24 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
};
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");
setSelectedAgentId(agentId);
setSelectedAgentVersion(agentVersion);
@ -116,14 +148,20 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
videoUrl: string,
categories: string[],
) => {
if (
!name ||
!subHeading ||
!description ||
!imageUrls.length ||
!categories.length
) {
console.error("Missing required fields");
const missingFields: string[] = [];
if (!name) missingFields.push("Name");
if (!subHeading) missingFields.push("Sub-heading");
if (!description) missingFields.push("Description");
if (!imageUrls.length) missingFields.push("Image");
if (!categories.length) missingFields.push("Categories");
if (missingFields.length > 0) {
toast({
title: "Missing Required Fields",
description: `Please fill in: ${missingFields.join(", ")}`,
duration: 3000,
});
return;
}
@ -203,6 +241,7 @@ export const PublishAgentPopout: React.FC<PublishAgentPopoutProps> = ({
onBack={handleBack}
onSubmit={handleNextFromInfo}
onClose={handleClose}
initialData={initialData}
/>
</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"}
className={cn("hide-password-toggle pr-10", className)}
ref={ref}
title="password"
{...props}
/>
<Button
@ -23,8 +24,11 @@ const PasswordInput = forwardRef<HTMLInputElement, InputProps>(
variant="ghost"
size="sm"
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}
tabIndex={-1}
>
{showPassword && !disabled ? (
<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;
}
.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 */
.handle-container {
display: flex;

View File

@ -21,6 +21,7 @@ interface SaveControlProps {
agentMeta: GraphMeta | null;
agentName: string;
agentDescription: string;
canSave: boolean;
onSave: () => void;
onNameChange: (name: 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.
* @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 {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 {(name: string) => void} SaveControlProps.onNameChange - Function to handle name changes.
* @param {(description: string) => void} SaveControlProps.onDescriptionChange - Function to handle description changes.
@ -38,6 +42,7 @@ interface SaveControlProps {
*/
export const SaveControl = ({
agentMeta,
canSave,
onSave,
agentName,
onNameChange,
@ -152,6 +157,7 @@ export const SaveControl = ({
onClick={handleSave}
data-id="save-control-save-agent"
data-testid="save-control-save-agent-button"
disabled={!canSave}
>
Save Agent
</Button>

View File

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

View File

@ -201,7 +201,7 @@ export const NodeGenericInputField: FC<{
className,
displayName,
}) => {
className = cn(className, "my-2");
className = cn(className);
displayName ||= propSchema.title || beautifyString(propKey);
if ("allOf" in propSchema) {
@ -876,18 +876,19 @@ const NodeArrayInput: FC<{
(c) => c.targetHandle === entryKey && c.target === nodeId,
);
return (
<div key={entryKey} className="self-start">
<div key={entryKey}>
<NodeHandle
keyName={entryKey}
schema={schema.items!}
isConnected={isConnected}
isRequired={false}
side="left"
/>
<div className="mb-2 flex space-x-2">
<NodeHandle
keyName={entryKey}
schema={schema.items!}
isConnected={isConnected}
isRequired={false}
side="left"
/>
{!isConnected &&
(schema.items ? (
<NodeGenericInputField
className="w-full"
nodeId={nodeId}
propKey={entryKey}
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));
for (let i = 1; i <= 5; i++) {
if (i <= rating) {
stars.push(<IconStarFilled key={i} className="text-black" />);
stars.push(
<IconStarFilled key={i} className="text-black dark:text-yellow-500" />,
);
} else {
stars.push(<IconStar key={i} className="text-black" />);
stars.push(
<IconStar key={i} className="text-black dark:text-yellow-500" />,
);
}
}
return stars;

View File

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

View File

@ -28,7 +28,11 @@ export default function useSupabase() {
const response = await supabase.auth.getUser();
if (response.error) {
console.error("Error fetching user", 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);
}
setUser(null);
} else {
setUser(response.data.user);
}

View File

@ -277,7 +277,11 @@ export default class BackendAPI {
username: string,
agentName: string,
): Promise<StoreAgentDetails> {
return this._get(`/store/agents/${username}/${agentName}`);
return this._get(
`/store/agents/${encodeURIComponent(username)}/${encodeURIComponent(
agentName,
)}`,
);
}
getStoreCreators(params?: {
@ -291,7 +295,7 @@ export default class BackendAPI {
}
getStoreCreator(username: string): Promise<CreatorDetails> {
return this._get(`/store/creator/${username}`);
return this._get(`/store/creator/${encodeURIComponent(username)}`);
}
getStoreSubmissions(params?: {
@ -307,6 +311,15 @@ export default class BackendAPI {
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> {
return this._request("DELETE", `/store/submissions/${submission_id}`);
}
@ -329,7 +342,9 @@ export default class BackendAPI {
console.log("Reviewing agent: ", username, agentName, review);
return this._request(
"POST",
`/store/agents/${username}/${agentName}/review`,
`/store/agents/${encodeURIComponent(username)}/${encodeURIComponent(
agentName,
)}/review`,
review,
);
}
@ -450,16 +465,6 @@ export default class BackendAPI {
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;
const payloadAsQuery = ["GET", "DELETE"].includes(method);

View File

@ -172,7 +172,7 @@ export type Node = {
position: { x: number; y: number };
[key: string]: any;
};
webhook_id?: string;
webhook?: Webhook;
};
/* Mirror of backend/data/graph.py:Link */
@ -314,6 +314,20 @@ export type APIKeyCredentials = BaseCredentials & {
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 = {
id: string;
email: string;
@ -325,6 +339,7 @@ export enum BlockUIType {
OUTPUT = "Output",
NOTE = "Note",
WEBHOOK = "Webhook",
WEBHOOK_MANUAL = "Webhook (manual)",
AGENT = "Agent",
}
@ -473,6 +488,7 @@ export type MyAgent = {
agent_version: number;
agent_name: string;
last_edited: string;
description: string;
};
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 */
export function safeCopyGraph(graph: Graph, block_defs: Block[]): Graph {
graph = removeAgentInputBlockValues(graph, block_defs);
return {
...graph,
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;
};
/** 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 {
// Deep clone the object to avoid modifying the original
const sanitizedObj = JSON.parse(JSON.stringify(obj));
// Sanitize the object
removeCredentials(sanitizedObj);
// 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 url = URL.createObjectURL(blob);

View File

@ -7,33 +7,28 @@ export class LoginPage {
console.log("Attempting login with:", { email, password }); // Debug log
// 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.fill(email);
// Fill password
const passwordInput = this.page.getByPlaceholder("password");
const passwordInput = this.page.getByTitle("Password");
await passwordInput.waitFor({ state: "visible" });
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
const emailInput2 = this.page.getByPlaceholder("user@email.com");
const emailInput2 = this.page.getByPlaceholder("m@example.com");
await emailInput2.waitFor({ state: "visible" });
await emailInput2.fill(email);
// Fill password
const passwordInput2 = this.page.getByPlaceholder("password");
const passwordInput2 = this.page.getByTitle("Password");
await passwordInput2.waitFor({ state: "visible" });
await passwordInput2.fill(password);
// Wait for the button to be ready
const loginButton = this.page.getByRole("button", {
name: "Log in",
name: "Login",
exact: true,
});
await loginButton.waitFor({ state: "visible" });

View File

@ -9,15 +9,15 @@ export class NavBar {
}
async clickMonitorLink() {
await this.page.getByRole("link", { name: "Library" }).click();
await this.page.getByTestId("navbar-link-library").click();
}
async clickBuildLink() {
await this.page.locator('a[href="/build"] div').click();
await this.page.getByTestId("navbar-link-build").click();
}
async clickMarketplaceLink() {
await this.page.locator('a[href="/store"]').click();
await this.page.getByTestId("navbar-link-marketplace").click();
}
async getUserMenuButton() {
@ -25,7 +25,7 @@ export class NavBar {
}
async clickUserMenu() {
await this.page.getByTestId("profile-popout-menu-trigger").click();
await (await this.getUserMenuButton()).click();
}
async logout() {
@ -35,7 +35,9 @@ export class NavBar {
async isLoggedIn(): Promise<boolean> {
try {
await this.page.getByTestId("profile-popout-menu-trigger").waitFor({
await (
await this.getUserMenuButton()
).waitFor({
state: "visible",
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"
tough-cookie "^4.1.4"
"@chromatic-com/storybook@^3.2.2":
version "3.2.2"
resolved "https://registry.yarnpkg.com/@chromatic-com/storybook/-/storybook-3.2.2.tgz#08754443de55618f802f88450c35266fd6d25db5"
integrity sha512-xmXt/GW0hAPbzNTrxYuVo43Adrtjue4DeVrsoIIEeJdGaPNNeNf+DHMlJKOBdlHmCnFUoe9R/0mLM9zUp5bKWw==
"@chromatic-com/storybook@^3.2.3":
version "3.2.3"
resolved "https://registry.yarnpkg.com/@chromatic-com/storybook/-/storybook-3.2.3.tgz#0f4d167ac80fcb38293a92c230c43446049b6758"
integrity sha512-3+hfANx79kIjP1qrOSLxpoAXOiYUA0S7A0WI0A24kASrv7USFNNW8etR5TjUilMb0LmqKUn3wDwUK2h6aceQ9g==
dependencies:
chromatic "^11.15.0"
filesize "^10.0.12"
@ -1698,10 +1698,10 @@
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.20.tgz#0be2cc955f4eb837516e7d7382284cd5bc1d5a02"
integrity sha512-JfDpuOCB0UBKlEgEy/H6qcBSzHimn/YWjUHzKl1jMeUO+QVRdzmTTl8gFJaNO87c8DXmVKhFCtwxQ9acqB3+Pw==
"@next/eslint-plugin-next@15.1.0":
version "15.1.0"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.0.tgz#421b47ad0772e11b2d67416054675cd32f95b8b7"
integrity sha512-+jPT0h+nelBT6HC9ZCHGc7DgGVy04cv4shYdAe6tKlEbjQUtwU3LzQhzbDHQyY2m6g39m6B0kOFVuLGBrxxbGg==
"@next/eslint-plugin-next@15.1.3":
version "15.1.3"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.3.tgz#32777736af151577df52d83f25c0c22bc9f3cb5e"
integrity sha512-oeP1vnc5Cq9UoOb8SYHAEPbCXMzOgG70l+Zfd+Ie00R25FOm+CCVNrcIubJvB1tvBgakXE37MmqSycksXVPRqg==
dependencies:
fast-glob "3.3.1"
@ -3213,10 +3213,10 @@
"@storybook/react-dom-shim" "8.4.7"
"@storybook/theming" "8.4.7"
"@storybook/test-runner@^0.20.1":
version "0.20.1"
resolved "https://registry.yarnpkg.com/@storybook/test-runner/-/test-runner-0.20.1.tgz#e2efa6266d512312a6b810db376da2919008cccd"
integrity sha512-3WU/th/uncIR6vpQDK9hKjiZjmczsluoLbgkRV7ufxY9IgHCGcbIjvT5EPS+XZIaOrNGjaPsyB5cE1okKn9iSA==
"@storybook/test-runner@^0.21.0":
version "0.21.0"
resolved "https://registry.yarnpkg.com/@storybook/test-runner/-/test-runner-0.21.0.tgz#31e7a6878e15a3f4d5555c57a135dd4d13fce9c4"
integrity sha512-aG2QvKXSIjMN1CA9PJK/esnidZWgt1gAkfo9Kqf8+NqBSsmP/2GyL5vxu1lkRFO/4qCv5JenNZ5Uj6ie4b2oag==
dependencies:
"@babel/core" "^7.22.5"
"@babel/generator" "^7.22.5"
@ -3258,21 +3258,21 @@
integrity sha512-99rgLEjf7iwfSEmdqlHkSG3AyLcK0sfExcr0jnc6rLiAkBhzuIsvcHjjUwkR210SOCgXqBPW0ZA6uhnuyppHLw==
"@stripe/stripe-js@^5.3.0":
version "5.3.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-5.3.0.tgz#56f9fe2ac0df8de714fd3c0c80ea4263d821795d"
integrity sha512-lNCZwCak1Yk0x2ecQO+4kcV7MwxAXapfgmLEh5SIoczc/r4GWAmcfyXZu3AAle+MAVW9HBe6f7tywuxJtYomcg==
version "5.4.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-5.4.0.tgz#847e870ddfe9283432526867857a4c1fba9b11ed"
integrity sha512-3tfMbSvLGB+OsJ2MsjWjWo+7sp29dwx+3+9kG/TEnZQJt+EwbF/Nomm43cSK+6oXZA9uhspgyrB+BbrPRumx4g==
"@supabase/auth-js@2.67.1":
version "2.67.1"
resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.67.1.tgz#b72217136df61d645dcfb7b12c7db8cbb7875a4c"
integrity sha512-1SRZG9VkLFz4rtiyEc1l49tMq9jTYu4wJt3pMQEWi7yshZFIBdVH1o5sshk1plQd5LY6GcrPIpCydM2gGDxchA==
"@supabase/auth-js@2.67.3":
version "2.67.3"
resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.67.3.tgz#a1f5eb22440b0cdbf87fe2ecae662a8dd8bb2028"
integrity sha512-NJDaW8yXs49xMvWVOkSIr8j46jf+tYHV0wHhrwOaLLMZSFO4g6kKAf+MfzQ2RaD06OCUkUHIzctLAxjTgEVpzw==
dependencies:
"@supabase/node-fetch" "^2.6.14"
"@supabase/functions-js@2.4.3":
version "2.4.3"
resolved "https://registry.yarnpkg.com/@supabase/functions-js/-/functions-js-2.4.3.tgz#ac1c696d3a1ebe00f60d5cea69b208078678ef8b"
integrity sha512-sOLXy+mWRyu4LLv1onYydq+10mNRQ4rzqQxNhbrKLTLTcdcmS9hbWif0bGz/NavmiQfPs4ZcmQJp4WqOXlR4AQ==
"@supabase/functions-js@2.4.4":
version "2.4.4"
resolved "https://registry.yarnpkg.com/@supabase/functions-js/-/functions-js-2.4.4.tgz#45fcd94d546bdfa66d01f93a796ca0304ec154b8"
integrity sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==
dependencies:
"@supabase/node-fetch" "^2.6.14"
@ -3316,12 +3316,12 @@
"@supabase/node-fetch" "^2.6.14"
"@supabase/supabase-js@^2.47.8":
version "2.47.8"
resolved "https://registry.yarnpkg.com/@supabase/supabase-js/-/supabase-js-2.47.8.tgz#6471a356b694e14170a00e6582bdbd0126944ec6"
integrity sha512-2GjK8/PrGJYDVBcjqGyM2irBLMQXvvkJLbS8VFPlym2uuNz+pPMnwLbNf5njkknUTy3PamjgIRoADpuPPPA6oA==
version "2.47.10"
resolved "https://registry.yarnpkg.com/@supabase/supabase-js/-/supabase-js-2.47.10.tgz#310ce81dc734116f9445dbce7f9341ae1c24d834"
integrity sha512-vJfPF820Ho5WILYHfKiBykDQ1SB9odTHrRZ0JxHfuLMC8GRvv21YLkUZQK7/rSVCkLvD6/ZwMWaOAfdUd//guw==
dependencies:
"@supabase/auth-js" "2.67.1"
"@supabase/functions-js" "2.4.3"
"@supabase/auth-js" "2.67.3"
"@supabase/functions-js" "2.4.4"
"@supabase/node-fetch" "2.6.15"
"@supabase/postgrest-js" "1.17.7"
"@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"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
concurrently@^9.0.1:
version "9.1.0"
resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.1.0.tgz#8da6d609f4321752912dab9be8710232ac496aa0"
integrity sha512-VxkzwMAn4LP7WyMnJNbHN5mKV9L2IbyDjpzemKr99sXNR3GqRNMMHdm7prV1ws9wg7ETj6WUkNOigZVsptwbgg==
concurrently@^9.1.1:
version "9.1.1"
resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.1.1.tgz#609dde2ce12f4f12d6a5ea6eace4c38bb7ab2ead"
integrity sha512-6VX8lrBIycgZKTwBsWS+bLrmkGRkDmvtGsYylRN9b93CygN6CbK46HmnQ3rdSOR8HRjdahDrxb5MqD9cEFOg5Q==
dependencies:
chalk "^4.1.2"
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"
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
eslint-config-next@15.1.0:
version "15.1.0"
resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-15.1.0.tgz#25a9a076b059905fd0cf3f6f832771724dfcbbdf"
integrity sha512-gADO+nKVseGso3DtOrYX9H7TxB/MuX7AUYhMlvQMqLYvUWu4HrOQuU7cC1HW74tHIqkAvXdwgAz3TCbczzSEXw==
eslint-config-next@15.1.3:
version "15.1.3"
resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-15.1.3.tgz#7656b47591745bcdbd60d396282924d89f82eea6"
integrity sha512-wGYlNuWnh4ujuKtZvH+7B2Z2vy9nONZE6ztd+DKF7hAsIabkrxmD4TzYHzASHENo42lmz2tnT2B+zN2sOHvpJg==
dependencies:
"@next/eslint-plugin-next" "15.1.0"
"@next/eslint-plugin-next" "15.1.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/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"
integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==
tailwindcss@^3.4.15:
version "3.4.16"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.16.tgz#35a7c3030844d6000fc271878db4096b6a8d2ec9"
integrity sha512-TI4Cyx7gDiZ6r44ewaJmt0o6BrMCT5aK5e0rmJ/G9Xq3w7CX/5VXl/zIPEJZFUK5VEqwByyhqNPycPlvcK4ZNw==
tailwindcss@^3.4.17:
version "3.4.17"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63"
integrity sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==
dependencies:
"@alloc/quick-lru" "^5.2.0"
arg "^5.0.2"

View File

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