Merge branch 'dev' into ntindle/open-2032-re-enable-getredditpostblock-sendemailblock
This commit is contained in:
commit
0a905c6d66
|
@ -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
|
|
@ -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", []
|
|
@ -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",
|
||||
)
|
|
@ -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)
|
||||
|
|
|
@ -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", []
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
|
|
|
@ -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,
|
|
@ -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
|
|
@ -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
|
|
@ -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__)
|
||||
|
||||
|
|
|
@ -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 "
|
||||
|
|
|
@ -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__)
|
||||
|
||||
|
|
|
@ -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"
|
||||
)
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
]
|
||||
|
|
|
@ -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)}")
|
|
@ -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"
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)}"
|
||||
)
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -25,7 +25,7 @@ export function NavBarButtons({ className }: { className?: string }) {
|
|||
},
|
||||
{
|
||||
href: "/store",
|
||||
text: "Agent Store",
|
||||
text: "Marketplace",
|
||||
icon: <IconMarketplace />,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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`:
|
||||
|
|
Loading…
Reference in New Issue