Merge branch 'dev' into ntindle/open-2032-re-enable-getredditpostblock-sendemailblock

This commit is contained in:
Nicholas Tindle 2024-12-20 12:36:20 -06:00
commit 0a905c6d66
No known key found for this signature in database
GPG Key ID: C4A2154D91363A47
63 changed files with 1662 additions and 377 deletions

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

@ -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."
@ -197,7 +199,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 +215,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 +245,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 +264,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 +295,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}")
@ -268,11 +270,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 +302,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 +368,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 +378,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 +391,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 +510,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 +521,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 +536,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

@ -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)

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

@ -149,7 +149,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)
@ -166,9 +166,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.")
@ -187,7 +187,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.")
@ -199,7 +199,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)
@ -209,7 +209,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:
@ -270,7 +270,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")
@ -440,7 +440,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)
@ -449,7 +449,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.")
@ -463,7 +465,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

@ -578,22 +578,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 +620,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,8 +631,9 @@ 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
@ -641,16 +643,19 @@ async def update_or_create_profile(
# 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": profile.username.lower(),
"description": profile.description,
"links": profile.links,
"links": profile.links or [],
"avatarUrl": profile.avatar_url,
"isFeatured": False,
}
)
@ -666,16 +671,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"] = profile.username.lower()
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 +757,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
@ -148,6 +151,9 @@ async def get_agent(
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)
agent = await backend.server.v2.store.db.get_store_agent_details(
username=username, agent_name=agent_name
)
@ -183,6 +189,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,
@ -253,8 +261,9 @@ async def get_creator(username: str) -> backend.server.v2.store.model.CreatorDet
- 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:
@ -439,3 +448,63 @@ async def upload_submission_media(
raise fastapi.HTTPException(
status_code=500, detail=f"Failed to upload media file: {str(e)}"
)
@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 as e:
logger.exception("Exception occurred whilst generating submission image")
raise fastapi.HTTPException(
status_code=500, detail=f"Failed to generate image: {str(e)}"
)

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

@ -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,26 @@ 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],
});
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 +72,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,7 +64,10 @@ 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">
{creator.description}
</div>
<CreatorLinks links={creator.links} />

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(
() =>
@ -726,6 +734,33 @@ export function CustomNode({
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>

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

@ -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

@ -14,7 +14,7 @@ interface BreadCrumbsProps {
export const BreadCrumbs: React.FC<BreadCrumbsProps> = ({ items }) => {
return (
<div className="flex items-center gap-4">
{/*
{/*
Commented out for now, but keeping until we have approval to remove
<button className="flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800">
<IconLeftArrow className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
@ -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

@ -50,7 +50,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
</div>
</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" />
@ -94,7 +94,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
</div>
</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

@ -38,13 +38,15 @@ export const AgentsSection: React.FC<AgentsSectionProps> = ({
const displayedAgents = allAgents.slice(0, 9);
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

@ -4,6 +4,74 @@
transition: border-color 0.3s ease-in-out;
}
.custom-node [data-id="input-handles"] {
padding: 0 1.25rem;
margin-bottom: 1rem;
}
.custom-node [data-id="input-handles"] > div > div {
margin-bottom: 1rem;
}
.handle-container {
display: flex;
position: relative;
margin-bottom: 0px;
padding: 0.75rem 1.25rem;
min-height: 44px;
height: 100%;
}
.custom-node input:not([type="checkbox"]),
.custom-node textarea,
.custom-node select {
width: calc(100% - 2.5rem);
max-width: 400px;
margin: 0.5rem 1.25rem;
}
.custom-node [data-id^="date-picker"] {
margin: 0.5rem 1.25rem;
width: calc(100% - 2.5rem);
}
.custom-node [data-list-container] {
margin: 0.5rem 1.25rem;
width: calc(100% - 2.5rem);
}
.custom-node [data-add-item] {
margin: 0.5rem 1.25rem;
width: calc(100% - 2.5rem);
padding: 0.5rem;
}
.array-item-container {
display: flex;
align-items: center;
margin: 0.5rem 1.25rem;
width: calc(100% - 2.5rem);
}
.custom-node [data-content-settings] {
margin: 0.5rem 1.25rem;
width: calc(100% - 2.5rem);
}
.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;
margin: 0.25rem 1.25rem;
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

@ -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

@ -280,7 +280,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?: {
@ -294,7 +298,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?: {
@ -310,6 +314,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,
);
}
c;
deleteStoreSubmission(submission_id: string): Promise<boolean> {
return this._request("DELETE", `/store/submissions/${submission_id}`);
}
@ -332,7 +345,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,
);
}
@ -453,16 +468,6 @@ export default class BackendAPI {
await new Promise((resolve) => setTimeout(resolve, 100 * retryCount));
}
}
console.log("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 */
@ -321,6 +321,20 @@ export type UserPasswordCredentials = BaseCredentials & {
password: string;
};
/* Mirror of backend/data/integrations.py:Webhook */
export 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;
@ -332,6 +346,7 @@ export enum BlockUIType {
OUTPUT = "Output",
NOTE = "Note",
WEBHOOK = "Webhook",
WEBHOOK_MANUAL = "Webhook (manual)",
AGENT = "Agent",
}
@ -480,6 +495,7 @@ export type MyAgent = {
agent_version: number;
agent_name: string;
last_edited: string;
description: string;
};
export type MyAgentsResponse = {

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

@ -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

@ -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`: