Merge remote-tracking branch 'origin/dev' into dev-store-v2

This commit is contained in:
SwiftyOS
2024-12-04 10:08:45 +01:00
39 changed files with 1717 additions and 51 deletions

View File

@@ -6,11 +6,13 @@ on:
paths:
- ".github/workflows/platform-backend-ci.yml"
- "autogpt_platform/backend/**"
- "autogpt_platform/autogpt_libs/**"
pull_request:
branches: [master, dev, release-*]
paths:
- ".github/workflows/platform-backend-ci.yml"
- "autogpt_platform/backend/**"
- "autogpt_platform/autogpt_libs/**"
merge_group:
concurrency:

View File

@@ -98,6 +98,11 @@ repos:
files: ^autogpt_platform/autogpt_libs/
args: [--fix]
- id: ruff-format
name: Format (Ruff) - AutoGPT Platform - Libs
alias: ruff-lint-platform-libs
files: ^autogpt_platform/autogpt_libs/
- repo: local
# isort needs the context of which packages are installed to function, so we
# can't use a vendored isort pre-commit hook (which runs in its own isolated venv).
@@ -140,7 +145,7 @@ repos:
# everything in .gitignore, so it works fine without any config or arguments.
hooks:
- id: black
name: Lint (Black)
name: Format (Black)
- repo: https://github.com/PyCQA/flake8
rev: 7.0.0

View File

@@ -72,7 +72,7 @@ def feature_flag(
"""
def decorator(
func: Callable[P, Union[T, Awaitable[T]]]
func: Callable[P, Union[T, Awaitable[T]]],
) -> Callable[P, Union[T, Awaitable[T]]]:
@wraps(func)
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:

View File

@@ -23,7 +23,6 @@ DEBUG_LOG_FORMAT = (
class LoggingConfig(BaseSettings):
level: str = Field(
default="INFO",
description="Logging level",

View File

@@ -24,10 +24,10 @@ from .utils import remove_color_codes
),
("", ""),
("hello", "hello"),
("hello\x1B[31m world", "hello world"),
("\x1B[36mHello,\x1B[32m World!", "Hello, World!"),
("hello\x1b[31m world", "hello world"),
("\x1b[36mHello,\x1b[32m World!", "Hello, World!"),
(
"\x1B[1m\x1B[31mError:\x1B[0m\x1B[31m file not found",
"\x1b[1m\x1b[31mError:\x1b[0m\x1b[31m file not found",
"Error: file not found",
),
],

View File

@@ -0,0 +1,31 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class RateLimitSettings(BaseSettings):
redis_host: str = Field(
default="redis://localhost:6379",
description="Redis host",
validation_alias="REDIS_HOST",
)
redis_port: str = Field(
default="6379", description="Redis port", validation_alias="REDIS_PORT"
)
redis_password: str = Field(
default="password",
description="Redis password",
validation_alias="REDIS_PASSWORD",
)
requests_per_minute: int = Field(
default=60,
description="Maximum number of requests allowed per minute per API key",
validation_alias="RATE_LIMIT_REQUESTS_PER_MINUTE",
)
model_config = SettingsConfigDict(case_sensitive=True, extra="ignore")
RATE_LIMIT_SETTINGS = RateLimitSettings()

View File

@@ -0,0 +1,51 @@
import time
from typing import Tuple
from redis import Redis
from .config import RATE_LIMIT_SETTINGS
class RateLimiter:
def __init__(
self,
redis_host: str = RATE_LIMIT_SETTINGS.redis_host,
redis_port: str = RATE_LIMIT_SETTINGS.redis_port,
redis_password: str = RATE_LIMIT_SETTINGS.redis_password,
requests_per_minute: int = RATE_LIMIT_SETTINGS.requests_per_minute,
):
self.redis = Redis(
host=redis_host,
port=int(redis_port),
password=redis_password,
decode_responses=True,
)
self.window = 60
self.max_requests = requests_per_minute
async def check_rate_limit(self, api_key_id: str) -> Tuple[bool, int, int]:
"""
Check if request is within rate limits.
Args:
api_key_id: The API key identifier to check
Returns:
Tuple of (is_allowed, remaining_requests, reset_time)
"""
now = time.time()
window_start = now - self.window
key = f"ratelimit:{api_key_id}:1min"
pipe = self.redis.pipeline()
pipe.zremrangebyscore(key, 0, window_start)
pipe.zadd(key, {str(now): now})
pipe.zcount(key, window_start, now)
pipe.expire(key, self.window)
_, _, request_count, _ = pipe.execute()
remaining = max(0, self.max_requests - request_count)
reset_time = int(now + self.window)
return request_count <= self.max_requests, remaining, reset_time

View File

@@ -0,0 +1,32 @@
from fastapi import HTTPException, Request
from starlette.middleware.base import RequestResponseEndpoint
from .limiter import RateLimiter
async def rate_limit_middleware(request: Request, call_next: RequestResponseEndpoint):
"""FastAPI middleware for rate limiting API requests."""
limiter = RateLimiter()
if not request.url.path.startswith("/api"):
return await call_next(request)
api_key = request.headers.get("Authorization")
if not api_key:
return await call_next(request)
api_key = api_key.replace("Bearer ", "")
is_allowed, remaining, reset_time = await limiter.check_rate_limit(api_key)
if not is_allowed:
raise HTTPException(
status_code=429, detail="Rate limit exceeded. Please try again later."
)
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(limiter.max_requests)
response.headers["X-RateLimit-Remaining"] = str(remaining)
response.headers["X-RateLimit-Reset"] = str(reset_time)
return response

View File

@@ -1415,29 +1415,29 @@ pyasn1 = ">=0.1.3"
[[package]]
name = "ruff"
version = "0.8.0"
version = "0.8.1"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea"},
{file = "ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b"},
{file = "ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a"},
{file = "ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99"},
{file = "ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c"},
{file = "ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9"},
{file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362"},
{file = "ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df"},
{file = "ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3"},
{file = "ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c"},
{file = "ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2"},
{file = "ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70"},
{file = "ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd"},
{file = "ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426"},
{file = "ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468"},
{file = "ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f"},
{file = "ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6"},
{file = "ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44"},
{file = "ruff-0.8.1-py3-none-linux_armv6l.whl", hash = "sha256:fae0805bd514066f20309f6742f6ee7904a773eb9e6c17c45d6b1600ca65c9b5"},
{file = "ruff-0.8.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8a4f7385c2285c30f34b200ca5511fcc865f17578383db154e098150ce0a087"},
{file = "ruff-0.8.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd054486da0c53e41e0086e1730eb77d1f698154f910e0cd9e0d64274979a209"},
{file = "ruff-0.8.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2029b8c22da147c50ae577e621a5bfbc5d1fed75d86af53643d7a7aee1d23871"},
{file = "ruff-0.8.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2666520828dee7dfc7e47ee4ea0d928f40de72056d929a7c5292d95071d881d1"},
{file = "ruff-0.8.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:333c57013ef8c97a53892aa56042831c372e0bb1785ab7026187b7abd0135ad5"},
{file = "ruff-0.8.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:288326162804f34088ac007139488dcb43de590a5ccfec3166396530b58fb89d"},
{file = "ruff-0.8.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b12c39b9448632284561cbf4191aa1b005882acbc81900ffa9f9f471c8ff7e26"},
{file = "ruff-0.8.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:364e6674450cbac8e998f7b30639040c99d81dfb5bbc6dfad69bc7a8f916b3d1"},
{file = "ruff-0.8.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b22346f845fec132aa39cd29acb94451d030c10874408dbf776af3aaeb53284c"},
{file = "ruff-0.8.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2f2f7a7e7648a2bfe6ead4e0a16745db956da0e3a231ad443d2a66a105c04fa"},
{file = "ruff-0.8.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:adf314fc458374c25c5c4a4a9270c3e8a6a807b1bec018cfa2813d6546215540"},
{file = "ruff-0.8.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a885d68342a231b5ba4d30b8c6e1b1ee3a65cf37e3d29b3c74069cdf1ee1e3c9"},
{file = "ruff-0.8.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d2c16e3508c8cc73e96aa5127d0df8913d2290098f776416a4b157657bee44c5"},
{file = "ruff-0.8.1-py3-none-win32.whl", hash = "sha256:93335cd7c0eaedb44882d75a7acb7df4b77cd7cd0d2255c93b28791716e81790"},
{file = "ruff-0.8.1-py3-none-win_amd64.whl", hash = "sha256:2954cdbe8dfd8ab359d4a30cd971b589d335a44d444b6ca2cb3d1da21b75e4b6"},
{file = "ruff-0.8.1-py3-none-win_arm64.whl", hash = "sha256:55873cc1a473e5ac129d15eccb3c008c096b94809d693fc7053f588b67822737"},
{file = "ruff-0.8.1.tar.gz", hash = "sha256:3583db9a6450364ed5ca3f3b4225958b24f78178908d5c4bc0f46251ccca898f"},
]
[[package]]
@@ -1852,4 +1852,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<4.0"
content-hash = "54bf6e076ec4d09be2307f07240018459dd6594efdc55a2dc2dc1d673184587e"
content-hash = "0aef772b321a1a00163abdc6a88efb21874b84bf25c68e84992c3ae3af026a17"

View File

@@ -21,7 +21,7 @@ supabase = "^2.10.0"
[tool.poetry.group.dev.dependencies]
redis = "^5.2.0"
ruff = "^0.8.0"
ruff = "^0.8.1"
[build-system]
requires = ["poetry-core"]

View File

@@ -15,10 +15,10 @@ modules = [
if f.is_file() and f.name != "__init__.py"
]
for module in modules:
if not re.match("^[a-z_.]+$", module):
if not re.match("^[a-z0-9_.]+$", module):
raise ValueError(
f"Block module {module} error: module name must be lowercase, "
"separated by underscores, and contain only alphabet characters"
"and contain only alphanumeric characters and underscores."
)
importlib.import_module(f".{module}", package=__name__)

View File

@@ -1,7 +1,7 @@
import logging
import time
from enum import Enum
from typing import Any, Dict
from typing import Any
import httpx
@@ -64,7 +64,7 @@ class AIVideoGeneratorBlock(Block):
},
)
def _get_headers(self, api_key: str) -> Dict[str, str]:
def _get_headers(self, api_key: str) -> dict[str, str]:
"""Get headers for FAL API requests."""
return {
"Authorization": f"Key {api_key}",
@@ -72,8 +72,8 @@ class AIVideoGeneratorBlock(Block):
}
def _submit_request(
self, url: str, headers: Dict[str, str], data: Dict[str, Any]
) -> Dict[str, Any]:
self, url: str, headers: dict[str, str], data: dict[str, Any]
) -> dict[str, Any]:
"""Submit a request to the FAL API."""
try:
response = httpx.post(url, headers=headers, json=data)
@@ -83,7 +83,7 @@ class AIVideoGeneratorBlock(Block):
logger.error(f"FAL API request failed: {str(e)}")
raise RuntimeError(f"Failed to submit request: {str(e)}")
def _poll_status(self, status_url: str, headers: Dict[str, str]) -> Dict[str, Any]:
def _poll_status(self, status_url: str, headers: dict[str, str]) -> dict[str, Any]:
"""Poll the status endpoint until completion or failure."""
try:
response = httpx.get(status_url, headers=headers)

View File

@@ -111,7 +111,9 @@ class GithubPullRequestTriggerBlock(GitHubTriggerBase, Block):
def __init__(self):
from backend.integrations.webhooks.github import GithubWebhookType
example_payload = json.loads(self.EXAMPLE_PAYLOAD_FILE.read_text())
example_payload = json.loads(
self.EXAMPLE_PAYLOAD_FILE.read_text(encoding="utf-8")
)
super().__init__(
id="6c60ec01-8128-419e-988f-96a063ee2fea",

View File

@@ -0,0 +1,71 @@
from enum import Enum
from typing import Literal
from pydantic import BaseModel, SecretStr
from backend.data.model import APIKeyCredentials, CredentialsField, CredentialsMetaInput
Slant3DCredentialsInput = CredentialsMetaInput[Literal["slant3d"], Literal["api_key"]]
def Slant3DCredentialsField() -> Slant3DCredentialsInput:
return CredentialsField(
provider="slant3d",
supported_credential_types={"api_key"},
description="Slant3D API key for authentication",
)
TEST_CREDENTIALS = APIKeyCredentials(
id="01234567-89ab-cdef-0123-456789abcdef",
provider="slant3d",
api_key=SecretStr("mock-slant3d-api-key"),
title="Mock Slant3D API key",
expires_at=None,
)
TEST_CREDENTIALS_INPUT = {
"provider": TEST_CREDENTIALS.provider,
"id": TEST_CREDENTIALS.id,
"type": TEST_CREDENTIALS.type,
"title": TEST_CREDENTIALS.title,
}
class CustomerDetails(BaseModel):
name: str
email: str
phone: str
address: str
city: str
state: str
zip: str
country_iso: str = "US"
is_residential: bool = True
class Color(Enum):
WHITE = "white"
BLACK = "black"
class Profile(Enum):
PLA = "PLA"
PETG = "PETG"
class OrderItem(BaseModel):
# filename: str
file_url: str
quantity: str # String as per API spec
color: Color = Color.WHITE
profile: Profile = Profile.PLA
# image_url: str = ""
# sku: str = ""
class Filament(BaseModel):
filament: str
hexColor: str
colorTag: str
profile: str

View File

@@ -0,0 +1,94 @@
from typing import Any, Dict
from backend.data.block import Block
from backend.util.request import requests
from ._api import Color, CustomerDetails, OrderItem, Profile
class Slant3DBlockBase(Block):
"""Base block class for Slant3D API interactions"""
BASE_URL = "https://www.slant3dapi.com/api"
def _get_headers(self, api_key: str) -> Dict[str, str]:
return {"api-key": api_key, "Content-Type": "application/json"}
def _make_request(self, method: str, endpoint: str, api_key: str, **kwargs) -> Dict:
url = f"{self.BASE_URL}/{endpoint}"
response = requests.request(
method=method, url=url, headers=self._get_headers(api_key), **kwargs
)
if not response.ok:
error_msg = response.json().get("error", "Unknown error")
raise RuntimeError(f"API request failed: {error_msg}")
return response.json()
def _check_valid_color(self, profile: Profile, color: Color, api_key: str) -> str:
response = self._make_request(
"GET",
"filament",
api_key,
params={"profile": profile.value, "color": color.value},
)
if profile == Profile.PLA:
color_tag = color.value
else:
color_tag = f"{profile.value.lower()}{color.value.capitalize()}"
valid_tags = [filament["colorTag"] for filament in response["filaments"]]
if color_tag not in valid_tags:
raise ValueError(
f"""Invalid color profile combination {color_tag}.
Valid colors for {profile.value} are:
{','.join([filament['colorTag'].replace(profile.value.lower(), '') for filament in response['filaments'] if filament['profile'] == profile.value])}
"""
)
return color_tag
def _convert_to_color(self, profile: Profile, color: Color, api_key: str) -> str:
return self._check_valid_color(profile, color, api_key)
def _format_order_data(
self,
customer: CustomerDetails,
order_number: str,
items: list[OrderItem],
api_key: str,
) -> list[dict[str, Any]]:
"""Helper function to format order data for API requests"""
orders = []
for item in items:
order_data = {
"email": customer.email,
"phone": customer.phone,
"name": customer.name,
"orderNumber": order_number,
"filename": item.file_url,
"fileURL": item.file_url,
"bill_to_street_1": customer.address,
"bill_to_city": customer.city,
"bill_to_state": customer.state,
"bill_to_zip": customer.zip,
"bill_to_country_as_iso": customer.country_iso,
"bill_to_is_US_residential": str(customer.is_residential).lower(),
"ship_to_name": customer.name,
"ship_to_street_1": customer.address,
"ship_to_city": customer.city,
"ship_to_state": customer.state,
"ship_to_zip": customer.zip,
"ship_to_country_as_iso": customer.country_iso,
"ship_to_is_US_residential": str(customer.is_residential).lower(),
"order_item_name": item.file_url,
"order_quantity": item.quantity,
"order_image_url": "",
"order_sku": "NOT_USED",
"order_item_color": self._convert_to_color(
item.profile, item.color, api_key
),
"profile": item.profile.value,
}
orders.append(order_data)
return orders

View File

@@ -0,0 +1,85 @@
from typing import List
from backend.data.block import BlockOutput, BlockSchema
from backend.data.model import APIKeyCredentials, SchemaField
from ._api import (
TEST_CREDENTIALS,
TEST_CREDENTIALS_INPUT,
Filament,
Slant3DCredentialsField,
Slant3DCredentialsInput,
)
from .base import Slant3DBlockBase
class Slant3DFilamentBlock(Slant3DBlockBase):
"""Block for retrieving available filaments"""
class Input(BlockSchema):
credentials: Slant3DCredentialsInput = Slant3DCredentialsField()
class Output(BlockSchema):
filaments: List[Filament] = SchemaField(
description="List of available filaments"
)
error: str = SchemaField(description="Error message if request failed")
def __init__(self):
super().__init__(
id="7cc416f4-f305-4606-9b3b-452b8a81031c",
description="Get list of available filaments",
input_schema=self.Input,
output_schema=self.Output,
test_input={"credentials": TEST_CREDENTIALS_INPUT},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"filaments",
[
{
"filament": "PLA BLACK",
"hexColor": "000000",
"colorTag": "black",
"profile": "PLA",
},
{
"filament": "PLA WHITE",
"hexColor": "ffffff",
"colorTag": "white",
"profile": "PLA",
},
],
)
],
test_mock={
"_make_request": lambda *args, **kwargs: {
"filaments": [
{
"filament": "PLA BLACK",
"hexColor": "000000",
"colorTag": "black",
"profile": "PLA",
},
{
"filament": "PLA WHITE",
"hexColor": "ffffff",
"colorTag": "white",
"profile": "PLA",
},
]
}
},
)
def run(
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
) -> BlockOutput:
try:
result = self._make_request(
"GET", "filament", credentials.api_key.get_secret_value()
)
yield "filaments", result["filaments"]
except Exception as e:
yield "error", str(e)
raise

View File

@@ -0,0 +1,418 @@
import uuid
from typing import List
import requests as baserequests
from backend.data.block import BlockOutput, BlockSchema
from backend.data.model import APIKeyCredentials, SchemaField
from backend.util import settings
from backend.util.settings import BehaveAs
from ._api import (
TEST_CREDENTIALS,
TEST_CREDENTIALS_INPUT,
CustomerDetails,
OrderItem,
Slant3DCredentialsField,
Slant3DCredentialsInput,
)
from .base import Slant3DBlockBase
class Slant3DCreateOrderBlock(Slant3DBlockBase):
"""Block for creating new orders"""
class Input(BlockSchema):
credentials: Slant3DCredentialsInput = Slant3DCredentialsField()
order_number: str = SchemaField(
description="Your custom order number (or leave blank for a random one)",
default_factory=lambda: str(uuid.uuid4()),
)
customer: CustomerDetails = SchemaField(
description="Customer details for where to ship the item",
advanced=False,
)
items: List[OrderItem] = SchemaField(
description="List of items to print",
advanced=False,
)
class Output(BlockSchema):
order_id: str = SchemaField(description="Slant3D order ID")
error: str = SchemaField(description="Error message if order failed")
def __init__(self):
super().__init__(
id="f73007d6-f48f-4aaf-9e6b-6883998a09b4",
description="Create a new print order",
input_schema=self.Input,
output_schema=self.Output,
test_input={
"credentials": TEST_CREDENTIALS_INPUT,
"order_number": "TEST-001",
"customer": {
"name": "John Doe",
"email": "john@example.com",
"phone": "123-456-7890",
"address": "123 Test St",
"city": "Test City",
"state": "TS",
"zip": "12345",
},
"items": [
{
"file_url": "https://example.com/model.stl",
"quantity": "1",
"color": "black",
"profile": "PLA",
}
],
},
test_credentials=TEST_CREDENTIALS,
test_output=[("order_id", "314144241")],
test_mock={
"_make_request": lambda *args, **kwargs: {"orderId": "314144241"},
"_convert_to_color": lambda *args, **kwargs: "black",
},
)
def run(
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
) -> BlockOutput:
try:
order_data = self._format_order_data(
input_data.customer,
input_data.order_number,
input_data.items,
credentials.api_key.get_secret_value(),
)
result = self._make_request(
"POST", "order", credentials.api_key.get_secret_value(), json=order_data
)
yield "order_id", result["orderId"]
except Exception as e:
yield "error", str(e)
raise
class Slant3DEstimateOrderBlock(Slant3DBlockBase):
"""Block for getting order cost estimates"""
class Input(BlockSchema):
credentials: Slant3DCredentialsInput = Slant3DCredentialsField()
order_number: str = SchemaField(
description="Your custom order number (or leave blank for a random one)",
default_factory=lambda: str(uuid.uuid4()),
)
customer: CustomerDetails = SchemaField(
description="Customer details for where to ship the item",
advanced=False,
)
items: List[OrderItem] = SchemaField(
description="List of items to print",
advanced=False,
)
class Output(BlockSchema):
total_price: float = SchemaField(description="Total price in USD")
shipping_cost: float = SchemaField(description="Shipping cost")
printing_cost: float = SchemaField(description="Printing cost")
error: str = SchemaField(description="Error message if estimation failed")
def __init__(self):
super().__init__(
id="bf8823d6-b42a-48c7-b558-d7c117f2ae85",
description="Get order cost estimate",
input_schema=self.Input,
output_schema=self.Output,
test_input={
"credentials": TEST_CREDENTIALS_INPUT,
"order_number": "TEST-001",
"customer": {
"name": "John Doe",
"email": "john@example.com",
"phone": "123-456-7890",
"address": "123 Test St",
"city": "Test City",
"state": "TS",
"zip": "12345",
},
"items": [
{
"file_url": "https://example.com/model.stl",
"quantity": "1",
"color": "black",
"profile": "PLA",
}
],
},
test_credentials=TEST_CREDENTIALS,
test_output=[
("total_price", 9.31),
("shipping_cost", 5.56),
("printing_cost", 3.75),
],
test_mock={
"_make_request": lambda *args, **kwargs: {
"totalPrice": 9.31,
"shippingCost": 5.56,
"printingCost": 3.75,
},
"_convert_to_color": lambda *args, **kwargs: "black",
},
)
def run(
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
) -> BlockOutput:
order_data = self._format_order_data(
input_data.customer,
input_data.order_number,
input_data.items,
credentials.api_key.get_secret_value(),
)
try:
result = self._make_request(
"POST",
"order/estimate",
credentials.api_key.get_secret_value(),
json=order_data,
)
yield "total_price", result["totalPrice"]
yield "shipping_cost", result["shippingCost"]
yield "printing_cost", result["printingCost"]
except baserequests.HTTPError as e:
yield "error", str(f"Error estimating order: {e} {e.response.text}")
raise
class Slant3DEstimateShippingBlock(Slant3DBlockBase):
"""Block for getting shipping cost estimates"""
class Input(BlockSchema):
credentials: Slant3DCredentialsInput = Slant3DCredentialsField()
order_number: str = SchemaField(
description="Your custom order number (or leave blank for a random one)",
default_factory=lambda: str(uuid.uuid4()),
)
customer: CustomerDetails = SchemaField(
description="Customer details for where to ship the item"
)
items: List[OrderItem] = SchemaField(
description="List of items to print",
advanced=False,
)
class Output(BlockSchema):
shipping_cost: float = SchemaField(description="Estimated shipping cost")
currency_code: str = SchemaField(description="Currency code (e.g., 'usd')")
error: str = SchemaField(description="Error message if estimation failed")
def __init__(self):
super().__init__(
id="00aae2a1-caf6-4a74-8175-39a0615d44e1",
description="Get shipping cost estimate",
input_schema=self.Input,
output_schema=self.Output,
test_input={
"credentials": TEST_CREDENTIALS_INPUT,
"order_number": "TEST-001",
"customer": {
"name": "John Doe",
"email": "john@example.com",
"phone": "123-456-7890",
"address": "123 Test St",
"city": "Test City",
"state": "TS",
"zip": "12345",
},
"items": [
{
"file_url": "https://example.com/model.stl",
"quantity": "1",
"color": "black",
"profile": "PLA",
}
],
},
test_credentials=TEST_CREDENTIALS,
test_output=[("shipping_cost", 4.81), ("currency_code", "usd")],
test_mock={
"_make_request": lambda *args, **kwargs: {
"shippingCost": 4.81,
"currencyCode": "usd",
},
"_convert_to_color": lambda *args, **kwargs: "black",
},
)
def run(
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
) -> BlockOutput:
try:
order_data = self._format_order_data(
input_data.customer,
input_data.order_number,
input_data.items,
credentials.api_key.get_secret_value(),
)
result = self._make_request(
"POST",
"order/estimateShipping",
credentials.api_key.get_secret_value(),
json=order_data,
)
yield "shipping_cost", result["shippingCost"]
yield "currency_code", result["currencyCode"]
except Exception as e:
yield "error", str(e)
raise
class Slant3DGetOrdersBlock(Slant3DBlockBase):
"""Block for retrieving all orders"""
class Input(BlockSchema):
credentials: Slant3DCredentialsInput = Slant3DCredentialsField()
class Output(BlockSchema):
orders: List[str] = SchemaField(description="List of orders with their details")
error: str = SchemaField(description="Error message if request failed")
def __init__(self):
super().__init__(
id="42283bf5-8a32-4fb4-92a2-60a9ea48e105",
description="Get all orders for the account",
input_schema=self.Input,
output_schema=self.Output,
# This block is disabled for cloud hosted because it allows access to all orders for the account
disabled=settings.Settings().config.behave_as == BehaveAs.CLOUD,
test_input={"credentials": TEST_CREDENTIALS_INPUT},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"orders",
[
"1234567890",
],
)
],
test_mock={
"_make_request": lambda *args, **kwargs: {
"ordersData": [
{
"orderId": 1234567890,
"orderTimestamp": {
"_seconds": 1719510986,
"_nanoseconds": 710000000,
},
}
]
}
},
)
def run(
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
) -> BlockOutput:
try:
result = self._make_request(
"GET", "order", credentials.api_key.get_secret_value()
)
yield "orders", [str(order["orderId"]) for order in result["ordersData"]]
except Exception as e:
yield "error", str(e)
raise
class Slant3DTrackingBlock(Slant3DBlockBase):
"""Block for tracking order status and shipping"""
class Input(BlockSchema):
credentials: Slant3DCredentialsInput = Slant3DCredentialsField()
order_id: str = SchemaField(description="Slant3D order ID to track")
class Output(BlockSchema):
status: str = SchemaField(description="Order status")
tracking_numbers: List[str] = SchemaField(
description="List of tracking numbers"
)
error: str = SchemaField(description="Error message if tracking failed")
def __init__(self):
super().__init__(
id="dd7c0293-c5af-4551-ba3e-fc162fb1fb89",
description="Track order status and shipping",
input_schema=self.Input,
output_schema=self.Output,
test_input={
"credentials": TEST_CREDENTIALS_INPUT,
"order_id": "314144241",
},
test_credentials=TEST_CREDENTIALS,
test_output=[("status", "awaiting_shipment"), ("tracking_numbers", [])],
test_mock={
"_make_request": lambda *args, **kwargs: {
"status": "awaiting_shipment",
"trackingNumbers": [],
}
},
)
def run(
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
) -> BlockOutput:
try:
result = self._make_request(
"GET",
f"order/{input_data.order_id}/get-tracking",
credentials.api_key.get_secret_value(),
)
yield "status", result["status"]
yield "tracking_numbers", result["trackingNumbers"]
except Exception as e:
yield "error", str(e)
raise
class Slant3DCancelOrderBlock(Slant3DBlockBase):
"""Block for canceling orders"""
class Input(BlockSchema):
credentials: Slant3DCredentialsInput = Slant3DCredentialsField()
order_id: str = SchemaField(description="Slant3D order ID to cancel")
class Output(BlockSchema):
status: str = SchemaField(description="Cancellation status message")
error: str = SchemaField(description="Error message if cancellation failed")
def __init__(self):
super().__init__(
id="54de35e1-407f-450b-b5fa-3b5e2eba8185",
description="Cancel an existing order",
input_schema=self.Input,
output_schema=self.Output,
test_input={
"credentials": TEST_CREDENTIALS_INPUT,
"order_id": "314144241",
},
test_credentials=TEST_CREDENTIALS,
test_output=[("status", "Order cancelled")],
test_mock={
"_make_request": lambda *args, **kwargs: {"status": "Order cancelled"}
},
)
def run(
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
) -> BlockOutput:
try:
result = self._make_request(
"DELETE",
f"order/{input_data.order_id}",
credentials.api_key.get_secret_value(),
)
yield "status", result["status"]
except Exception as e:
yield "error", str(e)
raise

View File

@@ -0,0 +1,61 @@
from backend.data.block import BlockOutput, BlockSchema
from backend.data.model import APIKeyCredentials, SchemaField
from ._api import (
TEST_CREDENTIALS,
TEST_CREDENTIALS_INPUT,
Slant3DCredentialsField,
Slant3DCredentialsInput,
)
from .base import Slant3DBlockBase
class Slant3DSlicerBlock(Slant3DBlockBase):
"""Block for slicing 3D model files"""
class Input(BlockSchema):
credentials: Slant3DCredentialsInput = Slant3DCredentialsField()
file_url: str = SchemaField(
description="URL of the 3D model file to slice (STL)"
)
class Output(BlockSchema):
message: str = SchemaField(description="Response message")
price: float = SchemaField(description="Calculated price for printing")
error: str = SchemaField(description="Error message if slicing failed")
def __init__(self):
super().__init__(
id="f8a12c8d-3e4b-4d5f-b6a7-8c9d0e1f2g3h",
description="Slice a 3D model file and get pricing information",
input_schema=self.Input,
output_schema=self.Output,
test_input={
"credentials": TEST_CREDENTIALS_INPUT,
"file_url": "https://example.com/model.stl",
},
test_credentials=TEST_CREDENTIALS,
test_output=[("message", "Slicing successful"), ("price", 8.23)],
test_mock={
"_make_request": lambda *args, **kwargs: {
"message": "Slicing successful",
"data": {"price": 8.23},
}
},
)
def run(
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
) -> BlockOutput:
try:
result = self._make_request(
"POST",
"slicer",
credentials.api_key.get_secret_value(),
json={"fileURL": input_data.file_url},
)
yield "message", result["message"]
yield "price", result["data"]["price"]
except Exception as e:
yield "error", str(e)
raise

View File

@@ -0,0 +1,125 @@
from pydantic import BaseModel
from backend.data.block import (
Block,
BlockCategory,
BlockOutput,
BlockSchema,
BlockWebhookConfig,
)
from backend.data.model import SchemaField
from backend.util import settings
from backend.util.settings import AppEnvironment, BehaveAs
from ._api import (
TEST_CREDENTIALS,
TEST_CREDENTIALS_INPUT,
Slant3DCredentialsField,
Slant3DCredentialsInput,
)
class Slant3DTriggerBase:
"""Base class for Slant3D webhook triggers"""
class Input(BlockSchema):
credentials: Slant3DCredentialsInput = Slant3DCredentialsField()
# Webhook URL is handled by the webhook system
payload: dict = SchemaField(hidden=True, default={})
class Output(BlockSchema):
payload: dict = SchemaField(
description="The complete webhook payload received from Slant3D"
)
order_id: str = SchemaField(description="The ID of the affected order")
error: str = SchemaField(
description="Error message if payload processing failed"
)
def run(self, input_data: Input, **kwargs) -> BlockOutput:
yield "payload", input_data.payload
yield "order_id", input_data.payload["orderId"]
class Slant3DOrderWebhookBlock(Slant3DTriggerBase, Block):
"""Block for handling Slant3D order webhooks"""
class Input(Slant3DTriggerBase.Input):
class EventsFilter(BaseModel):
"""
Currently Slant3D only supports 'SHIPPED' status updates
Could be expanded in the future with more status types
"""
shipped: bool = True
events: EventsFilter = SchemaField(
title="Events",
description="Order status events to subscribe to",
default=EventsFilter(shipped=True),
)
class Output(Slant3DTriggerBase.Output):
status: str = SchemaField(description="The new status of the order")
tracking_number: str = SchemaField(
description="The tracking number for the shipment"
)
carrier_code: str = SchemaField(description="The carrier code (e.g., 'usps')")
def __init__(self):
super().__init__(
id="8a74c2ad-0104-4640-962f-26c6b69e58cd",
description=(
"This block triggers on Slant3D order status updates and outputs "
"the event details, including tracking information when orders are shipped."
),
# All webhooks are currently subscribed to for all orders. This works for self hosted, but not for cloud hosted prod
disabled=(
settings.Settings().config.behave_as == BehaveAs.CLOUD
and settings.Settings().config.app_env != AppEnvironment.LOCAL
),
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=self.Input,
output_schema=self.Output,
webhook_config=BlockWebhookConfig(
provider="slant3d",
webhook_type="orders", # Only one type for now
resource_format="", # No resource format needed
event_filter_input="events",
event_format="order.{event}",
),
test_input={
"credentials": TEST_CREDENTIALS_INPUT,
"events": {"shipped": True},
"payload": {
"orderId": "1234567890",
"status": "SHIPPED",
"trackingNumber": "ABCDEF123456",
"carrierCode": "usps",
},
},
test_credentials=TEST_CREDENTIALS,
test_output=[
(
"payload",
{
"orderId": "1234567890",
"status": "SHIPPED",
"trackingNumber": "ABCDEF123456",
"carrierCode": "usps",
},
),
("order_id", "1234567890"),
("status", "SHIPPED"),
("tracking_number", "ABCDEF123456"),
("carrier_code", "usps"),
],
)
def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore
yield from super().run(input_data, **kwargs)
# Extract and normalize values from the payload
yield "status", input_data.payload["status"]
yield "tracking_number", input_data.payload["trackingNumber"]
yield "carrier_code", input_data.payload["carrierCode"]

View File

@@ -10,6 +10,7 @@ class BlockCostType(str, Enum):
RUN = "run" # cost X credits per run
BYTE = "byte" # cost X credits per byte
SECOND = "second" # cost X credits per second
DOLLAR = "dollar" # cost X dollars per run
class BlockCost(BaseModel):

View File

@@ -120,7 +120,7 @@ def SchemaField(
title: Optional[str] = None,
description: Optional[str] = None,
placeholder: Optional[str] = None,
advanced: Optional[bool] = None,
advanced: Optional[bool] = False,
secret: bool = False,
exclude: bool = False,
hidden: Optional[bool] = None,

View File

@@ -1,6 +1,7 @@
from typing import TYPE_CHECKING
from .github import GithubWebhooksManager
from .slant3d import Slant3DWebhooksManager
if TYPE_CHECKING:
from .base import BaseWebhooksManager
@@ -10,6 +11,7 @@ WEBHOOK_MANAGERS_BY_NAME: dict[str, type["BaseWebhooksManager"]] = {
handler.PROVIDER_NAME: handler
for handler in [
GithubWebhooksManager,
Slant3DWebhooksManager,
]
}
# --8<-- [end:WEBHOOK_MANAGERS_BY_NAME]

View File

@@ -0,0 +1,99 @@
import logging
from typing import ClassVar
import requests
from fastapi import Request
from backend.data import integrations
from backend.data.model import APIKeyCredentials, Credentials
from backend.integrations.webhooks.base import BaseWebhooksManager
logger = logging.getLogger(__name__)
class Slant3DWebhooksManager(BaseWebhooksManager):
"""Manager for Slant3D webhooks"""
PROVIDER_NAME: ClassVar[str] = "slant3d"
BASE_URL = "https://www.slant3dapi.com/api"
async def _register_webhook(
self,
credentials: Credentials,
webhook_type: str,
resource: str,
events: list[str],
ingress_url: str,
secret: str,
) -> tuple[str, dict]:
"""Register a new webhook with Slant3D"""
if not isinstance(credentials, APIKeyCredentials):
raise ValueError("API key is required to register a webhook")
headers = {
"api-key": credentials.api_key.get_secret_value(),
"Content-Type": "application/json",
}
# Slant3D's API doesn't use events list, just register for all order updates
payload = {"endPoint": ingress_url}
response = requests.post(
f"{self.BASE_URL}/customer/webhookSubscribe", headers=headers, json=payload
)
if not response.ok:
error = response.json().get("error", "Unknown error")
raise RuntimeError(f"Failed to register webhook: {error}")
webhook_config = {
"endpoint": ingress_url,
"provider": self.PROVIDER_NAME,
"events": ["order.shipped"], # Currently the only supported event
"type": webhook_type,
}
return "", webhook_config
@classmethod
async def validate_payload(
cls, webhook: integrations.Webhook, request: Request
) -> tuple[dict, str]:
"""Validate incoming webhook payload from Slant3D"""
payload = await request.json()
# Validate required fields from Slant3D API spec
required_fields = ["orderId", "status", "trackingNumber", "carrierCode"]
missing_fields = [field for field in required_fields if field not in payload]
if missing_fields:
raise ValueError(f"Missing required fields: {', '.join(missing_fields)}")
# Normalize payload structure
normalized_payload = {
"orderId": payload["orderId"],
"status": payload["status"],
"trackingNumber": payload["trackingNumber"],
"carrierCode": payload["carrierCode"],
}
# Currently Slant3D only sends shipping notifications
# Convert status to lowercase for event format compatibility
event_type = f"order.{payload['status'].lower()}"
return normalized_payload, event_type
async def _deregister_webhook(
self, webhook: integrations.Webhook, credentials: Credentials
) -> None:
"""
Note: Slant3D API currently doesn't provide a deregistration endpoint.
This would need to be handled through support.
"""
# Log warning since we can't properly deregister
logger.warning(
f"Warning: Manual deregistration required for webhook {webhook.id}"
)
pass

View File

@@ -2,7 +2,10 @@ import os
import subprocess
directory = os.path.dirname(os.path.realpath(__file__))
target_dirs = ["../backend", "../autogpt_libs"]
BACKEND_DIR = "."
LIBS_DIR = "../autogpt_libs"
TARGET_DIRS = [BACKEND_DIR, LIBS_DIR]
def run(*command: str) -> None:
@@ -12,17 +15,19 @@ def run(*command: str) -> None:
def lint():
try:
run("ruff", "check", *target_dirs, "--exit-zero")
run("isort", "--diff", "--check", "--profile", "black", ".")
run("black", "--diff", "--check", ".")
run("pyright", *target_dirs)
run("ruff", "check", *TARGET_DIRS, "--exit-zero")
run("ruff", "format", "--diff", "--check", LIBS_DIR)
run("isort", "--diff", "--check", "--profile", "black", BACKEND_DIR)
run("black", "--diff", "--check", BACKEND_DIR)
run("pyright", *TARGET_DIRS)
except subprocess.CalledProcessError as e:
print("Lint failed, try running `poetry run format` to fix the issues: ", e)
raise e
def format():
run("ruff", "check", "--fix", *target_dirs)
run("isort", "--profile", "black", ".")
run("black", ".")
run("pyright", *target_dirs)
run("ruff", "check", "--fix", *TARGET_DIRS)
run("ruff", "format", LIBS_DIR)
run("isort", "--profile", "black", BACKEND_DIR)
run("black", BACKEND_DIR)
run("pyright", *TARGET_DIRS)

View File

@@ -571,6 +571,17 @@ export function CustomNode({
className={`${blockClasses} ${errorClass} ${statusClass}`}
data-id={`custom-node-${id}`}
z-index={1}
data-blockid={data.block_id}
data-blockname={data.title}
data-blocktype={data.blockType}
data-nodetype={data.uiType}
data-category={data.categories[0]?.category.toLowerCase() || ""}
data-inputs={JSON.stringify(
Object.keys(data.inputSchema?.properties || {}),
)}
data-outputs={JSON.stringify(
Object.keys(data.outputSchema?.properties || {}),
)}
>
{/* Header */}
<div

View File

@@ -59,6 +59,7 @@ const NodeHandle: FC<HandleProps> = ({
<div key={keyName} className="handle-container">
<Handle
type="target"
data-testid={`input-handle-${keyName}`}
position={Position.Left}
id={keyName}
className="-ml-[26px]"
@@ -76,6 +77,7 @@ const NodeHandle: FC<HandleProps> = ({
<div key={keyName} className="handle-container justify-end">
<Handle
type="source"
data-testid={`output-handle-${keyName}`}
position={Position.Right}
id={keyName}
className="group -mr-[26px]"

View File

@@ -122,7 +122,8 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
variant="ghost"
size="icon"
data-id="blocks-control-popover-trigger"
className="dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
data-testid="blocks-control-blocks-button dark:hover:bg-slate-800"
name="Blocks"
>
<IconToyBrick />
</Button>
@@ -144,6 +145,7 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
htmlFor="search-blocks"
className="whitespace-nowrap text-base font-bold text-black dark:text-white 2xl:text-xl"
data-id="blocks-control-label"
data-testid="blocks-control-blocks-label"
>
Blocks
</Label>
@@ -206,6 +208,7 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
<span
className="block truncate pb-1 text-sm font-semibold dark:text-white"
data-id={`block-name-${block.id}`}
data-testid={`block-name-${block.id}`}
>
<TextRenderer
value={beautifyString(block.name).replace(
@@ -215,7 +218,10 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
truncateLengthLimit={45}
/>
</span>
<span className="block break-all text-xs font-normal text-gray-500 dark:text-gray-400">
<span
className="block break-all text-xs font-normal text-gray-500 dark:text-gray-400"
data-testid={`block-description-${block.id}`}
>
<TextRenderer
value={block.description}
truncateLengthLimit={165}
@@ -225,6 +231,7 @@ export const BlocksControl: React.FC<BlocksControlProps> = ({
<div
className="flex flex-shrink-0 items-center gap-1"
data-id={`block-tooltip-${block.id}`}
data-testid={`block-add`}
>
<PlusIcon className="h-6 w-6 rounded-lg bg-gray-200 stroke-black stroke-[0.5px] p-1 dark:bg-gray-700 dark:stroke-white" />
</div>

View File

@@ -59,6 +59,7 @@ export const ControlPanel = ({
size="icon"
onClick={() => control.onClick()}
data-id={`control-button-${index}`}
data-testid={`blocks-control-${control.label.toLowerCase()}-button`}
disabled={control.disabled || false}
className="dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
>

View File

@@ -91,6 +91,8 @@ export const SaveControl = ({
variant="ghost"
size="icon"
data-id="save-control-popover-trigger"
data-testid="blocks-control-save-button"
name="Save"
>
<IconSave className="dark:text-gray-300" />
</Button>
@@ -117,6 +119,7 @@ export const SaveControl = ({
value={agentName}
onChange={(e) => onNameChange(e.target.value)}
data-id="save-control-name-input"
data-testid="save-control-name-input"
maxLength={100}
/>
<Label htmlFor="description" className="dark:text-gray-300">
@@ -129,6 +132,7 @@ export const SaveControl = ({
value={agentDescription}
onChange={(e) => onDescriptionChange(e.target.value)}
data-id="save-control-description-input"
data-testid="save-control-description-input"
maxLength={500}
/>
{agentMeta?.version && (
@@ -142,6 +146,7 @@ export const SaveControl = ({
className="col-span-3"
value={agentMeta?.version || "-"}
disabled
data-testid="save-control-version-output"
/>
</>
)}
@@ -152,6 +157,7 @@ export const SaveControl = ({
className="w-full dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-800"
onClick={handleSave}
data-id="save-control-save-agent"
data-testid="save-control-save-agent-button"
>
Save {getType()}
</Button>

View File

@@ -63,6 +63,7 @@ export const providerIcons: Record<
openweathermap: fallbackIcon,
open_router: fallbackIcon,
pinecone: fallbackIcon,
slant3d: fallbackIcon,
replicate: fallbackIcon,
fal: fallbackIcon,
revid: fallbackIcon,

View File

@@ -37,6 +37,7 @@ const providerDisplayNames: Record<CredentialsProviderName, string> = {
openweathermap: "OpenWeatherMap",
open_router: "Open Router",
pinecone: "Pinecone",
slant3d: "Slant3D",
replicate: "Replicate",
fal: "FAL",
revid: "Rev.ID",

View File

@@ -40,6 +40,7 @@ export function NavBarButtons({ className }: { className?: string }) {
<Link
key={button.href}
href={button.href}
data-testid={`${button.text.toLowerCase()}-nav-link`}
className={cn(
className,
"flex items-center gap-2 rounded-xl p-3",
@@ -52,6 +53,31 @@ export function NavBarButtons({ className }: { className?: string }) {
</Link>
);
})}
{isCloud ? (
<Link
href="/marketplace"
data-testid="marketplace-nav-link"
className={cn(
className,
"flex items-center gap-2 rounded-xl p-3",
pathname === "/marketplace"
? "bg-gray-950 text-white"
: "text-muted-foreground hover:text-foreground",
)}
>
<LuShoppingCart /> Marketplace
</Link>
) : (
<MarketPopup
data-testid="marketplace-nav-link"
className={cn(
className,
"flex items-center gap-2 rounded-xl p-3 text-muted-foreground hover:text-foreground",
)}
>
<LuShoppingCart /> Marketplace
</MarketPopup>
)}
</>
);
}

View File

@@ -40,7 +40,11 @@ export function InputBlock({
</SelectTrigger>
<SelectContent>
{placeholder_values.map((placeholder, index) => (
<SelectItem key={index} value={placeholder.toString()}>
<SelectItem
key={index}
value={placeholder.toString()}
data-testid={`run-dialog-input-${name}-${placeholder.toString()}`}
>
{placeholder.toString()}
</SelectItem>
))}
@@ -49,6 +53,7 @@ export function InputBlock({
) : (
<Input
id={`${id}-Value`}
data-testid={`run-dialog-input-${name}`}
value={value}
onChange={(e) => onInputChange(id, "value", e.target.value)}
placeholder={placeholder_values?.[0]?.toString() || "Enter value"}

View File

@@ -72,6 +72,7 @@ export function RunnerInputUI({
</div>
<DialogFooter className="px-6 py-4">
<Button
data-testid="run-dialog-run-button"
onClick={scheduledInput ? handleSchedule : handleRun}
className="px-8 py-2 text-lg"
disabled={scheduledInput ? isScheduling : isRunning}

View File

@@ -115,6 +115,7 @@ export const PROVIDER_NAMES = {
OPENWEATHERMAP: "openweathermap",
OPEN_ROUTER: "open_router",
PINECONE: "pinecone",
SLANT3D: "slant3d",
REPLICATE: "replicate",
FAL: "fal",
REVID: "revid",

View File

@@ -0,0 +1,222 @@
// profile.spec.ts
import { test } from "./fixtures";
import { BuildPage } from "./pages/build.page";
test.describe("Build", () => {
let buildPage: BuildPage;
test.beforeEach(async ({ page, loginPage, testUser }, testInfo) => {
buildPage = new BuildPage(page);
// Start each test with login using worker auth
await page.goto("/login");
await loginPage.login(testUser.email, testUser.password);
await test.expect(page).toHaveURL("/");
await buildPage.navbar.clickBuildLink();
});
test("user can add a block", async ({ page }) => {
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
const block = {
id: "31d1064e-7446-4693-a7d4-65e5ca1180d1",
name: "Add to Dictionary",
description: "Add to Dictionary",
};
await buildPage.addBlock(block);
await buildPage.closeBlocksPanel();
await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy();
});
test("user can add all blocks", async ({ page }, testInfo) => {
// this test is slow af so we 10x the timeout (sorry future me)
await test.setTimeout(testInfo.timeout * 10);
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
const blocks = await buildPage.getBlocks();
// add all the blocks in order
for (const block of blocks) {
await buildPage.addBlock(block);
}
await buildPage.closeBlocksPanel();
// check that all the blocks are visible
for (const block of blocks) {
await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy();
}
// fill in the input for the agent input block
await buildPage.fillBlockInputByPlaceholder(
blocks.find((b) => b.name === "Agent Input")?.id ?? "",
"Enter Name",
"Agent Input Field",
);
await buildPage.fillBlockInputByPlaceholder(
blocks.find((b) => b.name === "Agent Output")?.id ?? "",
"Enter Name",
"Agent Output Field",
);
// check that we can save the agent with all the blocks
await buildPage.saveAgent("all blocks test", "all blocks test");
// page should have a url like http://localhost:3000/build?flowID=f4f3a1da-cfb3-430f-a074-a455b047e340
await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+"));
});
test("build navigation is accessible from navbar", async ({ page }) => {
await buildPage.navbar.clickBuildLink();
await test.expect(page).toHaveURL(new RegExp("/build"));
// workaround for #8788
await page.reload();
await page.reload();
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
});
test("user can add two blocks and connect them", async ({
page,
}, testInfo) => {
await test.setTimeout(testInfo.timeout * 10);
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
// Define the blocks to add
const block1 = {
id: "1ff065e9-88e8-4358-9d82-8dc91f622ba9",
name: "Store Value 1",
description: "Store Value Block 1",
};
const block2 = {
id: "1ff065e9-88e8-4358-9d82-8dc91f622ba9",
name: "Store Value 2",
description: "Store Value Block 2",
};
// Add the blocks
await buildPage.addBlock(block1);
await buildPage.addBlock(block2);
await buildPage.closeBlocksPanel();
// Connect the blocks
await buildPage.connectBlockOutputToBlockInputViaDataId(
"1-1-output-source",
"1-2-input-target",
);
// Fill in the input for the first block
await buildPage.fillBlockInputByPlaceholder(
block1.id,
"Enter input",
"Test Value",
"1",
);
// Save the agent and wait for the URL to update
await buildPage.saveAgent(
"Connected Blocks Test",
"Testing block connections",
);
await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+"));
// Wait for the save button to be enabled again
await buildPage.waitForSaveButton();
// Ensure the run button is enabled
await test.expect(buildPage.isRunButtonEnabled()).resolves.toBeTruthy();
// Run the agent
await buildPage.runAgent();
// Wait for processing to complete by checking the completion badge
await buildPage.waitForCompletionBadge();
// Get the first completion badge and verify it's visible
await test
.expect(buildPage.isCompletionBadgeVisible())
.resolves.toBeTruthy();
});
test("user can build an agent with inputs and output blocks", async ({
page,
}) => {
// simple caluclator to double input and output it
// load the pages and prep
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
// find the blocks we want
const blocks = await buildPage.getBlocks();
const inputBlock = blocks.find((b) => b.name === "Agent Input");
const outputBlock = blocks.find((b) => b.name === "Agent Output");
const calculatorBlock = blocks.find((b) => b.name === "Calculator");
if (!inputBlock || !outputBlock || !calculatorBlock) {
throw new Error("Input or output block not found");
}
// add the blocks
await buildPage.addBlock(inputBlock);
await buildPage.addBlock(outputBlock);
await buildPage.addBlock(calculatorBlock);
await buildPage.closeBlocksPanel();
await test.expect(buildPage.hasBlock(inputBlock)).resolves.toBeTruthy();
await test.expect(buildPage.hasBlock(outputBlock)).resolves.toBeTruthy();
await test
.expect(buildPage.hasBlock(calculatorBlock))
.resolves.toBeTruthy();
await buildPage.connectBlockOutputToBlockInputViaName(
inputBlock.id,
"Result",
calculatorBlock.id,
"A",
);
await buildPage.connectBlockOutputToBlockInputViaName(
inputBlock.id,
"Result",
calculatorBlock.id,
"B",
);
await buildPage.connectBlockOutputToBlockInputViaName(
calculatorBlock.id,
"Result",
outputBlock.id,
"Value",
);
await buildPage.fillBlockInputByPlaceholder(
inputBlock.id,
"Enter Name",
"Value",
);
await buildPage.fillBlockInputByPlaceholder(
outputBlock.id,
"Enter Name",
"Doubled",
);
await buildPage.selectBlockInputValue(
calculatorBlock.id,
"Operation",
"Add",
);
await buildPage.saveAgent(
"Input and Output Blocks Test",
"Testing input and output blocks",
);
await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+"));
await buildPage.runAgent();
await buildPage.fillRunDialog({
Value: "10",
});
await buildPage.clickRunDialogRunButton();
await buildPage.waitForCompletionBadge();
await test
.expect(buildPage.isCompletionBadgeVisible())
.resolves.toBeTruthy();
});
});

View File

@@ -0,0 +1,299 @@
import { ElementHandle, Locator, Page } from "@playwright/test";
import { BasePage } from "./base.page";
interface Block {
id: string;
name: string;
description: string;
}
export class BuildPage extends BasePage {
constructor(page: Page) {
super(page);
}
async closeTutorial(): Promise<void> {
await this.page.getByRole("button", { name: "Skip Tutorial" }).click();
}
async openBlocksPanel(): Promise<void> {
if (
!(await this.page.getByTestId("blocks-control-blocks-label").isVisible())
) {
await this.page.getByTestId("blocks-control-blocks-button").click();
}
}
async closeBlocksPanel(): Promise<void> {
if (
await this.page.getByTestId("blocks-control-blocks-label").isVisible()
) {
await this.page.getByTestId("blocks-control-blocks-button").click();
}
}
async saveAgent(
name: string = "Test Agent",
description: string = "",
): Promise<void> {
await this.page.getByTestId("blocks-control-save-button").click();
await this.page.getByTestId("save-control-name-input").fill(name);
await this.page
.getByTestId("save-control-description-input")
.fill(description);
await this.page.getByTestId("save-control-save-agent-button").click();
}
async getBlocks(): Promise<Block[]> {
try {
const blocks = await this.page.locator('[data-id^="block-card-"]').all();
console.log(`found ${blocks.length} blocks`);
const results = await Promise.all(
blocks.map(async (block) => {
try {
const fullId = (await block.getAttribute("data-id")) || "";
const id = fullId.replace("block-card-", "");
const nameElement = block.locator('[data-testid^="block-name-"]');
const descriptionElement = block.locator(
'[data-testid^="block-description-"]',
);
const name = (await nameElement.textContent()) || "";
const description = (await descriptionElement.textContent()) || "";
return {
id,
name: name.trim(),
description: description.trim(),
};
} catch (elementError) {
console.error("Error processing block:", elementError);
return null;
}
}),
);
// Filter out any null results from errors
return results.filter((block): block is Block => block !== null);
} catch (error) {
console.error("Error getting blocks:", error);
return [];
}
}
async addBlock(block: Block): Promise<void> {
console.log(`adding block ${block.id} ${block.name} to agent`);
await this.page.getByTestId(`block-name-${block.id}`).click();
}
async isRFNodeVisible(nodeId: string): Promise<boolean> {
return await this.page.getByTestId(`rf__node-${nodeId}`).isVisible();
}
async hasBlock(block: Block): Promise<boolean> {
try {
// Use both ID and name for most precise matching
const node = await this.page
.locator(`[data-blockid="${block.id}"]`)
.first();
return await node.isVisible();
} catch (error) {
console.error("Error checking for block:", error);
return false;
}
}
async getBlockInputs(blockId: string): Promise<string[]> {
try {
const node = await this.page
.locator(`[data-blockid="${blockId}"]`)
.first();
const inputsData = await node.getAttribute("data-inputs");
return inputsData ? JSON.parse(inputsData) : [];
} catch (error) {
console.error("Error getting block inputs:", error);
return [];
}
}
async getBlockOutputs(blockId: string): Promise<string[]> {
throw new Error("Not implemented");
// try {
// const node = await this.page
// .locator(`[data-blockid="${blockId}"]`)
// .first();
// const outputsData = await node.getAttribute("data-outputs");
// return outputsData ? JSON.parse(outputsData) : [];
// } catch (error) {
// console.error("Error getting block outputs:", error);
// return [];
// }
}
async build_block_selector(
blockId: string,
dataId?: string,
): Promise<string> {
let selector = dataId
? `[data-id="${dataId}"] [data-blockid="${blockId}"]`
: `[data-blockid="${blockId}"]`;
return selector;
}
async getBlockById(blockId: string, dataId?: string): Promise<Locator> {
return await this.page.locator(
await this.build_block_selector(blockId, dataId),
);
}
// dataId is optional, if provided, it will start the search with that container, otherwise it will start with the blockId
// this is useful if you have multiple blocks with the same id, but different dataIds which you should have when adding a block to the graph.
// Do note that once you run an agent, the dataId will change, so you will need to update the tests to use the new dataId or not use the same block in tests that run an agent
async fillBlockInputByPlaceholder(
blockId: string,
placeholder: string,
value: string,
dataId?: string,
): Promise<void> {
const block = await this.getBlockById(blockId, dataId);
const input = await block.getByPlaceholder(placeholder);
await input.fill(value);
}
async selectBlockInputValue(
blockId: string,
inputName: string,
value: string,
dataId?: string,
): Promise<void> {
// First get the button that opens the dropdown
const baseSelector = await this.build_block_selector(blockId, dataId);
// Find the combobox button within the input handle container
const comboboxSelector = `${baseSelector} [data-id="input-handle-${inputName.toLowerCase()}"] button[role="combobox"]`;
try {
// Click the combobox to open it
await this.page.click(comboboxSelector);
// Wait a moment for the dropdown to open
await this.page.waitForTimeout(100);
// Select the option from the dropdown
// The actual selector for the option might need adjustment based on the dropdown structure
await this.page.getByRole("option", { name: value }).click();
} catch (error) {
console.error(
`Error selecting value "${value}" for input "${inputName}":`,
error,
);
throw error;
}
}
async fillBlockInputByLabel(
blockId: string,
label: string,
value: string,
): Promise<void> {
// throw new Error("Not implemented");
const block = await this.getBlockById(blockId);
const input = await block.getByLabel(label);
await input.fill(value);
}
async connectBlockOutputToBlockInputViaDataId(
blockOutputId: string,
blockInputId: string,
): Promise<void> {
try {
// Locate the output element
const outputElement = await this.page.locator(
`[data-id="${blockOutputId}"]`,
);
// Locate the input element
const inputElement = await this.page.locator(
`[data-id="${blockInputId}"]`,
);
await outputElement.dragTo(inputElement);
} catch (error) {
console.error("Error connecting block output to input:", error);
}
}
async connectBlockOutputToBlockInputViaName(
startBlockId: string,
startBlockOutputName: string,
endBlockId: string,
endBlockInputName: string,
startDataId?: string,
endDataId?: string,
): Promise<void> {
const startBlockBase = await this.build_block_selector(
startBlockId,
startDataId,
);
const endBlockBase = await this.build_block_selector(endBlockId, endDataId);
// Use descendant combinator to find test-id at any depth
const startBlockOutputSelector = `${startBlockBase} [data-testid="output-handle-${startBlockOutputName.toLowerCase()}"]`;
const endBlockInputSelector = `${endBlockBase} [data-testid="input-handle-${endBlockInputName.toLowerCase()}"]`;
// Log for debugging
console.log("Start block selector:", startBlockOutputSelector);
console.log("End block selector:", endBlockInputSelector);
await this.page
.locator(startBlockOutputSelector)
.dragTo(this.page.locator(endBlockInputSelector));
}
async isLoaded(): Promise<boolean> {
try {
await this.page.waitForLoadState("networkidle", { timeout: 10_000 });
return true;
} catch (error) {
return false;
}
}
async isRunButtonEnabled(): Promise<boolean> {
const runButton = this.page.locator('[data-id="primary-action-run-agent"]');
return await runButton.isEnabled();
}
async runAgent(): Promise<void> {
const runButton = this.page.locator('[data-id="primary-action-run-agent"]');
await runButton.click();
}
async fillRunDialog(inputs: Record<string, string>): Promise<void> {
for (const [key, value] of Object.entries(inputs)) {
await this.page.getByTestId(`run-dialog-input-${key}`).fill(value);
}
}
async clickRunDialogRunButton(): Promise<void> {
await this.page.getByTestId("run-dialog-run-button").click();
}
async waitForCompletionBadge(): Promise<void> {
await this.page.waitForSelector(
'[data-id^="badge-"][data-id$="-COMPLETED"]',
);
}
async waitForSaveButton(): Promise<void> {
await this.page.waitForSelector(
'[data-testid="blocks-control-save-button"]:not([disabled])',
);
}
async isCompletionBadgeVisible(): Promise<boolean> {
const completionBadge = this.page
.locator('[data-id^="badge-"][data-id$="-COMPLETED"]')
.first();
return await completionBadge.isVisible();
}
}

View File

@@ -11,15 +11,15 @@ export class NavBar {
}
async clickMonitorLink() {
await this.page.getByTestId("monitor-link").click();
await this.page.getByTestId("monitor-nav-link").click();
}
async clickBuildLink() {
await this.page.getByTestId("build-link").click();
await this.page.getByTestId("build-nav-link").click();
}
async clickMarketplaceLink() {
await this.page.getByTestId("marketplace-link").click();
await this.page.getByTestId("marketplace-nav-link").click();
}
async getUserMenuButton() {