mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge remote-tracking branch 'origin/dev' into dev-store-v2
This commit is contained in:
2
.github/workflows/platform-backend-ci.yml
vendored
2
.github/workflows/platform-backend-ci.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -23,7 +23,6 @@ DEBUG_LOG_FORMAT = (
|
||||
|
||||
|
||||
class LoggingConfig(BaseSettings):
|
||||
|
||||
level: str = Field(
|
||||
default="INFO",
|
||||
description="Logging level",
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
40
autogpt_platform/autogpt_libs/poetry.lock
generated
40
autogpt_platform/autogpt_libs/poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
71
autogpt_platform/backend/backend/blocks/slant3d/_api.py
Normal file
71
autogpt_platform/backend/backend/blocks/slant3d/_api.py
Normal 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
|
||||
94
autogpt_platform/backend/backend/blocks/slant3d/base.py
Normal file
94
autogpt_platform/backend/backend/blocks/slant3d/base.py
Normal 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
|
||||
85
autogpt_platform/backend/backend/blocks/slant3d/filament.py
Normal file
85
autogpt_platform/backend/backend/blocks/slant3d/filament.py
Normal 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
|
||||
418
autogpt_platform/backend/backend/blocks/slant3d/order.py
Normal file
418
autogpt_platform/backend/backend/blocks/slant3d/order.py
Normal 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
|
||||
61
autogpt_platform/backend/backend/blocks/slant3d/slicing.py
Normal file
61
autogpt_platform/backend/backend/blocks/slant3d/slicing.py
Normal 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
|
||||
125
autogpt_platform/backend/backend/blocks/slant3d/webhook.py
Normal file
125
autogpt_platform/backend/backend/blocks/slant3d/webhook.py
Normal 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"]
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -63,6 +63,7 @@ export const providerIcons: Record<
|
||||
openweathermap: fallbackIcon,
|
||||
open_router: fallbackIcon,
|
||||
pinecone: fallbackIcon,
|
||||
slant3d: fallbackIcon,
|
||||
replicate: fallbackIcon,
|
||||
fal: fallbackIcon,
|
||||
revid: fallbackIcon,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -115,6 +115,7 @@ export const PROVIDER_NAMES = {
|
||||
OPENWEATHERMAP: "openweathermap",
|
||||
OPEN_ROUTER: "open_router",
|
||||
PINECONE: "pinecone",
|
||||
SLANT3D: "slant3d",
|
||||
REPLICATE: "replicate",
|
||||
FAL: "fal",
|
||||
REVID: "revid",
|
||||
|
||||
222
autogpt_platform/frontend/src/tests/build.spec.ts
Normal file
222
autogpt_platform/frontend/src/tests/build.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
299
autogpt_platform/frontend/src/tests/pages/build.page.ts
Normal file
299
autogpt_platform/frontend/src/tests/pages/build.page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user