diff --git a/autogpt_platform/backend/backend/blocks/github/pull_requests.py b/autogpt_platform/backend/backend/blocks/github/pull_requests.py index cd5c7a358..e8fad2daa 100644 --- a/autogpt_platform/backend/backend/blocks/github/pull_requests.py +++ b/autogpt_platform/backend/backend/blocks/github/pull_requests.py @@ -1,4 +1,3 @@ -import base64 import re from typing_extensions import TypedDict @@ -513,330 +512,3 @@ def prepare_pr_api_url(pr_url: str, path: str) -> str: base_url, pr_number = match.groups() return f"{base_url}/pulls/{pr_number}/{path}" - - -class GithubCreateFileBlock(Block): - class Input(BlockSchema): - credentials: GithubCredentialsInput = GithubCredentialsField("repo") - repo_url: str = SchemaField( - description="URL of the GitHub repository", - placeholder="https://github.com/owner/repo", - ) - file_path: str = SchemaField( - description="Path where the file should be created", - placeholder="path/to/file.txt", - ) - content: str = SchemaField( - description="Content to write to the file", - placeholder="File content here", - ) - branch: str = SchemaField( - description="Branch where the file should be created", - default="main", - ) - commit_message: str = SchemaField( - description="Message for the commit", - default="Create new file", - ) - - class Output(BlockSchema): - url: str = SchemaField(description="URL of the created file") - sha: str = SchemaField(description="SHA of the commit") - error: str = SchemaField( - description="Error message if the file creation failed" - ) - - def __init__(self): - super().__init__( - id="8fd132ac-b917-428a-8159-d62893e8a3fe", - description="This block creates a new file in a GitHub repository.", - categories={BlockCategory.DEVELOPER_TOOLS}, - input_schema=GithubCreateFileBlock.Input, - output_schema=GithubCreateFileBlock.Output, - test_input={ - "repo_url": "https://github.com/owner/repo", - "file_path": "test/file.txt", - "content": "Test content", - "branch": "main", - "commit_message": "Create test file", - "credentials": TEST_CREDENTIALS_INPUT, - }, - test_credentials=TEST_CREDENTIALS, - test_output=[ - ("url", "https://github.com/owner/repo/blob/main/test/file.txt"), - ("sha", "abc123"), - ], - test_mock={ - "create_file": lambda *args, **kwargs: ( - "https://github.com/owner/repo/blob/main/test/file.txt", - "abc123", - ) - }, - ) - - @staticmethod - def create_file( - credentials: GithubCredentials, - repo_url: str, - file_path: str, - content: str, - branch: str, - commit_message: str, - ) -> tuple[str, str]: - api = get_api(credentials) - # Convert content to base64 - content_bytes = content.encode("utf-8") - content_base64 = base64.b64encode(content_bytes).decode("utf-8") - - # Create the file using the GitHub API - contents_url = f"{repo_url}/contents/{file_path}" - data = { - "message": commit_message, - "content": content_base64, - "branch": branch, - } - response = api.put(contents_url, json=data) - result = response.json() - - return result["content"]["html_url"], result["commit"]["sha"] - - def run( - self, - input_data: Input, - *, - credentials: GithubCredentials, - **kwargs, - ) -> BlockOutput: - try: - url, sha = self.create_file( - credentials, - input_data.repo_url, - input_data.file_path, - input_data.content, - input_data.branch, - input_data.commit_message, - ) - yield "url", url - yield "sha", sha - except Exception as e: - yield "error", str(e) - - -class GithubUpdateFileBlock(Block): - class Input(BlockSchema): - credentials: GithubCredentialsInput = GithubCredentialsField("repo") - repo_url: str = SchemaField( - description="URL of the GitHub repository", - placeholder="https://github.com/owner/repo", - ) - file_path: str = SchemaField( - description="Path to the file to update", - placeholder="path/to/file.txt", - ) - content: str = SchemaField( - description="New content for the file", - placeholder="Updated content here", - ) - branch: str = SchemaField( - description="Branch containing the file", - default="main", - ) - commit_message: str = SchemaField( - description="Message for the commit", - default="Update file", - ) - - class Output(BlockSchema): - url: str = SchemaField(description="URL of the updated file") - sha: str = SchemaField(description="SHA of the commit") - error: str = SchemaField(description="Error message if the file update failed") - - def __init__(self): - super().__init__( - id="30be12a4-57cb-4aa4-baf5-fcc68d136076", - description="This block updates an existing file in a GitHub repository.", - categories={BlockCategory.DEVELOPER_TOOLS}, - input_schema=GithubUpdateFileBlock.Input, - output_schema=GithubUpdateFileBlock.Output, - test_input={ - "repo_url": "https://github.com/owner/repo", - "file_path": "test/file.txt", - "content": "Updated content", - "branch": "main", - "commit_message": "Update test file", - "credentials": TEST_CREDENTIALS_INPUT, - }, - test_credentials=TEST_CREDENTIALS, - test_output=[ - ("url", "https://github.com/owner/repo/blob/main/test/file.txt"), - ("sha", "def456"), - ], - test_mock={ - "update_file": lambda *args, **kwargs: ( - "https://github.com/owner/repo/blob/main/test/file.txt", - "def456", - ) - }, - ) - - @staticmethod - def update_file( - credentials: GithubCredentials, - repo_url: str, - file_path: str, - content: str, - branch: str, - commit_message: str, - ) -> tuple[str, str]: - api = get_api(credentials) - - # First get the current file to get its SHA - contents_url = f"{repo_url}/contents/{file_path}" - params = {"ref": branch} - response = api.get(contents_url, params=params) - current_file = response.json() - - # Convert new content to base64 - content_bytes = content.encode("utf-8") - content_base64 = base64.b64encode(content_bytes).decode("utf-8") - - # Update the file - data = { - "message": commit_message, - "content": content_base64, - "sha": current_file["sha"], - "branch": branch, - } - response = api.put(contents_url, json=data) - result = response.json() - - return result["content"]["html_url"], result["commit"]["sha"] - - def run( - self, - input_data: Input, - *, - credentials: GithubCredentials, - **kwargs, - ) -> BlockOutput: - try: - url, sha = self.update_file( - credentials, - input_data.repo_url, - input_data.file_path, - input_data.content, - input_data.branch, - input_data.commit_message, - ) - yield "url", url - yield "sha", sha - except Exception as e: - yield "error", str(e) - - -class GithubCreateRepositoryBlock(Block): - class Input(BlockSchema): - credentials: GithubCredentialsInput = GithubCredentialsField("repo") - name: str = SchemaField( - description="Name of the repository to create", - placeholder="my-new-repo", - ) - description: str = SchemaField( - description="Description of the repository", - placeholder="A description of the repository", - default="", - ) - private: bool = SchemaField( - description="Whether the repository should be private", - default=False, - ) - auto_init: bool = SchemaField( - description="Whether to initialize the repository with a README", - default=True, - ) - gitignore_template: str = SchemaField( - description="Git ignore template to use (e.g., Python, Node, Java)", - default="", - ) - - class Output(BlockSchema): - url: str = SchemaField(description="URL of the created repository") - clone_url: str = SchemaField(description="Git clone URL of the repository") - error: str = SchemaField( - description="Error message if the repository creation failed" - ) - - def __init__(self): - super().__init__( - id="029ec3b8-1cfd-46d3-b6aa-28e4a706efd1", - description="This block creates a new GitHub repository.", - categories={BlockCategory.DEVELOPER_TOOLS}, - input_schema=GithubCreateRepositoryBlock.Input, - output_schema=GithubCreateRepositoryBlock.Output, - test_input={ - "name": "test-repo", - "description": "A test repository", - "private": False, - "auto_init": True, - "gitignore_template": "Python", - "credentials": TEST_CREDENTIALS_INPUT, - }, - test_credentials=TEST_CREDENTIALS, - test_output=[ - ("url", "https://github.com/owner/test-repo"), - ("clone_url", "https://github.com/owner/test-repo.git"), - ], - test_mock={ - "create_repository": lambda *args, **kwargs: ( - "https://github.com/owner/test-repo", - "https://github.com/owner/test-repo.git", - ) - }, - ) - - @staticmethod - def create_repository( - credentials: GithubCredentials, - name: str, - description: str, - private: bool, - auto_init: bool, - gitignore_template: str, - ) -> tuple[str, str]: - api = get_api(credentials, convert_urls=False) # Disable URL conversion - data = { - "name": name, - "description": description, - "private": private, - "auto_init": auto_init, - } - - if gitignore_template: - data["gitignore_template"] = gitignore_template - - # Create repository using the user endpoint - response = api.post("https://api.github.com/user/repos", json=data) - result = response.json() - - return result["html_url"], result["clone_url"] - - def run( - self, - input_data: Input, - *, - credentials: GithubCredentials, - **kwargs, - ) -> BlockOutput: - try: - url, clone_url = self.create_repository( - credentials, - input_data.name, - input_data.description, - input_data.private, - input_data.auto_init, - input_data.gitignore_template, - ) - yield "url", url - yield "clone_url", clone_url - except Exception as e: - yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/github/repo.py b/autogpt_platform/backend/backend/blocks/github/repo.py index 7e2521181..82bef9475 100644 --- a/autogpt_platform/backend/backend/blocks/github/repo.py +++ b/autogpt_platform/backend/backend/blocks/github/repo.py @@ -699,3 +699,420 @@ class GithubDeleteBranchBlock(Block): input_data.branch, ) yield "status", status + + +class GithubCreateFileBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + repo_url: str = SchemaField( + description="URL of the GitHub repository", + placeholder="https://github.com/owner/repo", + ) + file_path: str = SchemaField( + description="Path where the file should be created", + placeholder="path/to/file.txt", + ) + content: str = SchemaField( + description="Content to write to the file", + placeholder="File content here", + ) + branch: str = SchemaField( + description="Branch where the file should be created", + default="main", + ) + commit_message: str = SchemaField( + description="Message for the commit", + default="Create new file", + ) + + class Output(BlockSchema): + url: str = SchemaField(description="URL of the created file") + sha: str = SchemaField(description="SHA of the commit") + error: str = SchemaField( + description="Error message if the file creation failed" + ) + + def __init__(self): + super().__init__( + id="8fd132ac-b917-428a-8159-d62893e8a3fe", + description="This block creates a new file in a GitHub repository.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubCreateFileBlock.Input, + output_schema=GithubCreateFileBlock.Output, + test_input={ + "repo_url": "https://github.com/owner/repo", + "file_path": "test/file.txt", + "content": "Test content", + "branch": "main", + "commit_message": "Create test file", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("url", "https://github.com/owner/repo/blob/main/test/file.txt"), + ("sha", "abc123"), + ], + test_mock={ + "create_file": lambda *args, **kwargs: ( + "https://github.com/owner/repo/blob/main/test/file.txt", + "abc123", + ) + }, + ) + + @staticmethod + def create_file( + credentials: GithubCredentials, + repo_url: str, + file_path: str, + content: str, + branch: str, + commit_message: str, + ) -> tuple[str, str]: + api = get_api(credentials) + # Convert content to base64 + content_bytes = content.encode("utf-8") + content_base64 = base64.b64encode(content_bytes).decode("utf-8") + + # Create the file using the GitHub API + contents_url = f"{repo_url}/contents/{file_path}" + data = { + "message": commit_message, + "content": content_base64, + "branch": branch, + } + response = api.put(contents_url, json=data) + result = response.json() + + return result["content"]["html_url"], result["commit"]["sha"] + + def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + url, sha = self.create_file( + credentials, + input_data.repo_url, + input_data.file_path, + input_data.content, + input_data.branch, + input_data.commit_message, + ) + yield "url", url + yield "sha", sha + except Exception as e: + yield "error", str(e) + + +class GithubUpdateFileBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + repo_url: str = SchemaField( + description="URL of the GitHub repository", + placeholder="https://github.com/owner/repo", + ) + file_path: str = SchemaField( + description="Path to the file to update", + placeholder="path/to/file.txt", + ) + content: str = SchemaField( + description="New content for the file", + placeholder="Updated content here", + ) + branch: str = SchemaField( + description="Branch containing the file", + default="main", + ) + commit_message: str = SchemaField( + description="Message for the commit", + default="Update file", + ) + + class Output(BlockSchema): + url: str = SchemaField(description="URL of the updated file") + sha: str = SchemaField(description="SHA of the commit") + error: str = SchemaField(description="Error message if the file update failed") + + def __init__(self): + super().__init__( + id="30be12a4-57cb-4aa4-baf5-fcc68d136076", + description="This block updates an existing file in a GitHub repository.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubUpdateFileBlock.Input, + output_schema=GithubUpdateFileBlock.Output, + test_input={ + "repo_url": "https://github.com/owner/repo", + "file_path": "test/file.txt", + "content": "Updated content", + "branch": "main", + "commit_message": "Update test file", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("url", "https://github.com/owner/repo/blob/main/test/file.txt"), + ("sha", "def456"), + ], + test_mock={ + "update_file": lambda *args, **kwargs: ( + "https://github.com/owner/repo/blob/main/test/file.txt", + "def456", + ) + }, + ) + + @staticmethod + def update_file( + credentials: GithubCredentials, + repo_url: str, + file_path: str, + content: str, + branch: str, + commit_message: str, + ) -> tuple[str, str]: + api = get_api(credentials) + + # First get the current file to get its SHA + contents_url = f"{repo_url}/contents/{file_path}" + params = {"ref": branch} + response = api.get(contents_url, params=params) + current_file = response.json() + + # Convert new content to base64 + content_bytes = content.encode("utf-8") + content_base64 = base64.b64encode(content_bytes).decode("utf-8") + + # Update the file + data = { + "message": commit_message, + "content": content_base64, + "sha": current_file["sha"], + "branch": branch, + } + response = api.put(contents_url, json=data) + result = response.json() + + return result["content"]["html_url"], result["commit"]["sha"] + + def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + url, sha = self.update_file( + credentials, + input_data.repo_url, + input_data.file_path, + input_data.content, + input_data.branch, + input_data.commit_message, + ) + yield "url", url + yield "sha", sha + except Exception as e: + yield "error", str(e) + + +class GithubCreateRepositoryBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + name: str = SchemaField( + description="Name of the repository to create", + placeholder="my-new-repo", + ) + description: str = SchemaField( + description="Description of the repository", + placeholder="A description of the repository", + default="", + ) + private: bool = SchemaField( + description="Whether the repository should be private", + default=False, + ) + auto_init: bool = SchemaField( + description="Whether to initialize the repository with a README", + default=True, + ) + gitignore_template: str = SchemaField( + description="Git ignore template to use (e.g., Python, Node, Java)", + default="", + ) + + class Output(BlockSchema): + url: str = SchemaField(description="URL of the created repository") + clone_url: str = SchemaField(description="Git clone URL of the repository") + error: str = SchemaField( + description="Error message if the repository creation failed" + ) + + def __init__(self): + super().__init__( + id="029ec3b8-1cfd-46d3-b6aa-28e4a706efd1", + description="This block creates a new GitHub repository.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubCreateRepositoryBlock.Input, + output_schema=GithubCreateRepositoryBlock.Output, + test_input={ + "name": "test-repo", + "description": "A test repository", + "private": False, + "auto_init": True, + "gitignore_template": "Python", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("url", "https://github.com/owner/test-repo"), + ("clone_url", "https://github.com/owner/test-repo.git"), + ], + test_mock={ + "create_repository": lambda *args, **kwargs: ( + "https://github.com/owner/test-repo", + "https://github.com/owner/test-repo.git", + ) + }, + ) + + @staticmethod + def create_repository( + credentials: GithubCredentials, + name: str, + description: str, + private: bool, + auto_init: bool, + gitignore_template: str, + ) -> tuple[str, str]: + api = get_api(credentials, convert_urls=False) # Disable URL conversion + data = { + "name": name, + "description": description, + "private": private, + "auto_init": auto_init, + } + + if gitignore_template: + data["gitignore_template"] = gitignore_template + + # Create repository using the user endpoint + response = api.post("https://api.github.com/user/repos", json=data) + result = response.json() + + return result["html_url"], result["clone_url"] + + def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + url, clone_url = self.create_repository( + credentials, + input_data.name, + input_data.description, + input_data.private, + input_data.auto_init, + input_data.gitignore_template, + ) + yield "url", url + yield "clone_url", clone_url + except Exception as e: + yield "error", str(e) + + +class GithubListStargazersBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + repo_url: str = SchemaField( + description="URL of the GitHub repository", + placeholder="https://github.com/owner/repo", + ) + + class Output(BlockSchema): + class StargazerItem(TypedDict): + username: str + url: str + + stargazer: StargazerItem = SchemaField( + title="Stargazer", + description="Stargazers with their username and profile URL", + ) + error: str = SchemaField( + description="Error message if listing stargazers failed" + ) + + def __init__(self): + super().__init__( + id="a4b9c2d1-e5f6-4g7h-8i9j-0k1l2m3n4o5p", # Generated unique UUID + description="This block lists all users who have starred a specified GitHub repository.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubListStargazersBlock.Input, + output_schema=GithubListStargazersBlock.Output, + test_input={ + "repo_url": "https://github.com/owner/repo", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "stargazer", + { + "username": "octocat", + "url": "https://github.com/octocat", + }, + ) + ], + test_mock={ + "list_stargazers": lambda *args, **kwargs: [ + { + "username": "octocat", + "url": "https://github.com/octocat", + } + ] + }, + ) + + @staticmethod + def list_stargazers( + credentials: GithubCredentials, repo_url: str + ) -> list[Output.StargazerItem]: + api = get_api(credentials) + # Add /stargazers to the repo URL to get stargazers endpoint + stargazers_url = f"{repo_url}/stargazers" + # Set accept header to get starred_at timestamp + headers = {"Accept": "application/vnd.github.star+json"} + response = api.get(stargazers_url, headers=headers) + data = response.json() + + stargazers: list[GithubListStargazersBlock.Output.StargazerItem] = [ + { + "username": stargazer["login"], + "url": stargazer["html_url"], + } + for stargazer in data + ] + return stargazers + + def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + stargazers = self.list_stargazers( + credentials, + input_data.repo_url, + ) + yield from (("stargazer", stargazer) for stargazer in stargazers) + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/data/block.py b/autogpt_platform/backend/backend/data/block.py index ec4616b13..bcb74def7 100644 --- a/autogpt_platform/backend/backend/data/block.py +++ b/autogpt_platform/backend/backend/data/block.py @@ -22,10 +22,10 @@ from backend.util import json from backend.util.settings import Config from .model import ( - CREDENTIALS_FIELD_NAME, ContributorDetails, Credentials, CredentialsMetaInput, + is_credentials_field_name, ) app_config = Config() @@ -138,17 +138,38 @@ class BlockSchema(BaseModel): @classmethod def __pydantic_init_subclass__(cls, **kwargs): """Validates the schema definition. Rules: - - Only one `CredentialsMetaInput` field may be present. - - This field MUST be called `credentials`. - - A field that is called `credentials` MUST be a `CredentialsMetaInput`. + - Fields with annotation `CredentialsMetaInput` MUST be + named `credentials` or `*_credentials` + - Fields named `credentials` or `*_credentials` MUST be + of type `CredentialsMetaInput` """ super().__pydantic_init_subclass__(**kwargs) # Reset cached JSON schema to prevent inheriting it from parent class cls.cached_jsonschema = {} - credentials_fields = [ - field_name + credentials_fields = cls.get_credentials_fields() + + for field_name in cls.get_fields(): + if is_credentials_field_name(field_name): + if field_name not in credentials_fields: + raise TypeError( + f"Credentials field '{field_name}' on {cls.__qualname__} " + f"is not of type {CredentialsMetaInput.__name__}" + ) + + credentials_fields[field_name].validate_credentials_field_schema(cls) + + elif field_name in credentials_fields: + raise KeyError( + f"Credentials field '{field_name}' on {cls.__qualname__} " + "has invalid name: must be 'credentials' or *_credentials" + ) + + @classmethod + def get_credentials_fields(cls) -> dict[str, type[CredentialsMetaInput]]: + return { + field_name: info.annotation for field_name, info in cls.model_fields.items() if ( inspect.isclass(info.annotation) @@ -157,32 +178,7 @@ class BlockSchema(BaseModel): CredentialsMetaInput, ) ) - ] - if len(credentials_fields) > 1: - raise ValueError( - f"{cls.__qualname__} can only have one CredentialsMetaInput field" - ) - elif ( - len(credentials_fields) == 1 - and credentials_fields[0] != CREDENTIALS_FIELD_NAME - ): - raise ValueError( - f"CredentialsMetaInput field on {cls.__qualname__} " - "must be named 'credentials'" - ) - elif ( - len(credentials_fields) == 0 - and CREDENTIALS_FIELD_NAME in cls.model_fields.keys() - ): - raise TypeError( - f"Field 'credentials' on {cls.__qualname__} " - f"must be of type {CredentialsMetaInput.__name__}" - ) - if credentials_field := cls.model_fields.get(CREDENTIALS_FIELD_NAME): - credentials_input_type = cast( - CredentialsMetaInput, credentials_field.annotation - ) - credentials_input_type.validate_credentials_field_schema(cls) + } BlockSchemaInputType = TypeVar("BlockSchemaInputType", bound=BlockSchema) @@ -255,7 +251,7 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]): test_input: BlockInput | list[BlockInput] | None = None, test_output: BlockData | list[BlockData] | None = None, test_mock: dict[str, Any] | None = None, - test_credentials: Optional[Credentials] = None, + test_credentials: Optional[Credentials | dict[str, Credentials]] = None, disabled: bool = False, static_output: bool = False, block_type: BlockType = BlockType.STANDARD, @@ -297,10 +293,16 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]): if self.webhook_config: if isinstance(self.webhook_config, BlockWebhookConfig): # Enforce presence of credentials field on auto-setup webhook blocks - if CREDENTIALS_FIELD_NAME not in self.input_schema.model_fields: + if not (cred_fields := self.input_schema.get_credentials_fields()): raise TypeError( "credentials field is required on auto-setup webhook blocks" ) + # Disallow multiple credentials inputs on webhook blocks + elif len(cred_fields) > 1: + raise ValueError( + "Multiple credentials inputs not supported on webhook blocks" + ) + self.block_type = BlockType.WEBHOOK else: self.block_type = BlockType.WEBHOOK_MANUAL diff --git a/autogpt_platform/backend/backend/data/graph.py b/autogpt_platform/backend/backend/data/graph.py index 75449febe..7dd99a75e 100644 --- a/autogpt_platform/backend/backend/data/graph.py +++ b/autogpt_platform/backend/backend/data/graph.py @@ -424,6 +424,26 @@ class GraphModel(Graph): result[key] = value return result + def clean_graph(self): + blocks = [block() for block in get_blocks().values()] + + input_blocks = [ + node + for node in self.nodes + if next( + ( + b + for b in blocks + if b.id == node.block_id and b.block_type == BlockType.INPUT + ), + None, + ) + ] + + for node in self.nodes: + if any(input_block.id == node.id for input_block in input_blocks): + node.input_default["value"] = "" + # --------------------- CRUD functions --------------------- # diff --git a/autogpt_platform/backend/backend/data/model.py b/autogpt_platform/backend/backend/data/model.py index e22e9718b..71c2e7ec8 100644 --- a/autogpt_platform/backend/backend/data/model.py +++ b/autogpt_platform/backend/backend/data/model.py @@ -245,7 +245,8 @@ CP = TypeVar("CP", bound=ProviderName) CT = TypeVar("CT", bound=CredentialsType) -CREDENTIALS_FIELD_NAME = "credentials" +def is_credentials_field_name(field_name: str) -> bool: + return field_name == "credentials" or field_name.endswith("_credentials") class CredentialsMetaInput(BaseModel, Generic[CP, CT]): @@ -254,21 +255,21 @@ class CredentialsMetaInput(BaseModel, Generic[CP, CT]): provider: CP type: CT - @staticmethod - def _add_json_schema_extra(schema, cls: CredentialsMetaInput): - schema["credentials_provider"] = get_args( - cls.model_fields["provider"].annotation - ) - schema["credentials_types"] = get_args(cls.model_fields["type"].annotation) + @classmethod + def allowed_providers(cls) -> tuple[ProviderName, ...]: + return get_args(cls.model_fields["provider"].annotation) - model_config = ConfigDict( - json_schema_extra=_add_json_schema_extra, # type: ignore - ) + @classmethod + def allowed_cred_types(cls) -> tuple[CredentialsType, ...]: + return get_args(cls.model_fields["type"].annotation) @classmethod def validate_credentials_field_schema(cls, model: type["BlockSchema"]): - """Validates the schema of a `credentials` field""" - field_schema = model.jsonschema()["properties"][CREDENTIALS_FIELD_NAME] + """Validates the schema of a credentials input field""" + field_name = next( + name for name, type in model.get_credentials_fields().items() if type is cls + ) + field_schema = model.jsonschema()["properties"][field_name] try: schema_extra = _CredentialsFieldSchemaExtra[CP, CT].model_validate( field_schema @@ -282,11 +283,20 @@ class CredentialsMetaInput(BaseModel, Generic[CP, CT]): f"{field_schema}" ) from e - if ( - len(schema_extra.credentials_provider) > 1 - and not schema_extra.discriminator - ): - raise TypeError("Multi-provider CredentialsField requires discriminator!") + if len(cls.allowed_providers()) > 1 and not schema_extra.discriminator: + raise TypeError( + f"Multi-provider CredentialsField '{field_name}' " + "requires discriminator!" + ) + + @staticmethod + def _add_json_schema_extra(schema, cls: CredentialsMetaInput): + schema["credentials_provider"] = cls.allowed_providers() + schema["credentials_types"] = cls.allowed_cred_types() + + model_config = ConfigDict( + json_schema_extra=_add_json_schema_extra, # type: ignore + ) class _CredentialsFieldSchemaExtra(BaseModel, Generic[CP, CT]): diff --git a/autogpt_platform/backend/backend/executor/manager.py b/autogpt_platform/backend/backend/executor/manager.py index b46c45c1c..3ecd2395a 100644 --- a/autogpt_platform/backend/backend/executor/manager.py +++ b/autogpt_platform/backend/backend/executor/manager.py @@ -10,7 +10,6 @@ from contextlib import contextmanager from multiprocessing.pool import AsyncResult, Pool from typing import TYPE_CHECKING, Any, Generator, TypeVar, cast -from pydantic import BaseModel from redis.lock import Lock as RedisLock if TYPE_CHECKING: @@ -20,7 +19,14 @@ from autogpt_libs.utils.cache import thread_cached from backend.blocks.agent import AgentExecutorBlock from backend.data import redis -from backend.data.block import Block, BlockData, BlockInput, BlockType, get_block +from backend.data.block import ( + Block, + BlockData, + BlockInput, + BlockSchema, + BlockType, + get_block, +) from backend.data.execution import ( ExecutionQueue, ExecutionResult, @@ -31,7 +37,6 @@ from backend.data.execution import ( parse_execution_output, ) from backend.data.graph import GraphModel, Link, Node -from backend.data.model import CREDENTIALS_FIELD_NAME, CredentialsMetaInput from backend.integrations.creds_manager import IntegrationCredentialsManager from backend.util import json from backend.util.decorator import error_logged, time_measured @@ -170,10 +175,11 @@ def execute_node( # one (running) block at a time; simultaneous execution of blocks using same # credentials is not supported. creds_lock = None - if CREDENTIALS_FIELD_NAME in input_data: - credentials_meta = CredentialsMetaInput(**input_data[CREDENTIALS_FIELD_NAME]) + input_model = cast(type[BlockSchema], node_block.input_schema) + for field_name, input_type in input_model.get_credentials_fields().items(): + credentials_meta = input_type(**input_data[field_name]) credentials, creds_lock = creds_manager.acquire(user_id, credentials_meta.id) - extra_exec_kwargs["credentials"] = credentials + extra_exec_kwargs[field_name] = credentials output_size = 0 end_status = ExecutionStatus.COMPLETED @@ -890,41 +896,39 @@ class ExecutionManager(AppService): raise ValueError(f"Unknown block {node.block_id} for node #{node.id}") # Find any fields of type CredentialsMetaInput - model_fields = cast(type[BaseModel], block.input_schema).model_fields - if CREDENTIALS_FIELD_NAME not in model_fields: + credentials_fields = cast( + type[BlockSchema], block.input_schema + ).get_credentials_fields() + if not credentials_fields: continue - field = model_fields[CREDENTIALS_FIELD_NAME] - - # The BlockSchema class enforces that a `credentials` field is always a - # `CredentialsMetaInput`, so we can safely assume this here. - credentials_meta_type = cast(CredentialsMetaInput, field.annotation) - credentials_meta = credentials_meta_type.model_validate( - node.input_default[CREDENTIALS_FIELD_NAME] - ) - # Fetch the corresponding Credentials and perform sanity checks - credentials = self.credentials_store.get_creds_by_id( - user_id, credentials_meta.id - ) - if not credentials: - raise ValueError( - f"Unknown credentials #{credentials_meta.id} " - f"for node #{node.id}" + for field_name, credentials_meta_type in credentials_fields.items(): + credentials_meta = credentials_meta_type.model_validate( + node.input_default[field_name] ) - if ( - credentials.provider != credentials_meta.provider - or credentials.type != credentials_meta.type - ): - logger.warning( - f"Invalid credentials #{credentials.id} for node #{node.id}: " - "type/provider mismatch: " - f"{credentials_meta.type}<>{credentials.type};" - f"{credentials_meta.provider}<>{credentials.provider}" - ) - raise ValueError( - f"Invalid credentials #{credentials.id} for node #{node.id}: " - "type/provider mismatch" + # Fetch the corresponding Credentials and perform sanity checks + credentials = self.credentials_store.get_creds_by_id( + user_id, credentials_meta.id ) + if not credentials: + raise ValueError( + f"Unknown credentials #{credentials_meta.id} " + f"for node #{node.id} input '{field_name}'" + ) + if ( + credentials.provider != credentials_meta.provider + or credentials.type != credentials_meta.type + ): + logger.warning( + f"Invalid credentials #{credentials.id} for node #{node.id}: " + "type/provider mismatch: " + f"{credentials_meta.type}<>{credentials.type};" + f"{credentials_meta.provider}<>{credentials.provider}" + ) + raise ValueError( + f"Invalid credentials #{credentials.id} for node #{node.id}: " + "type/provider mismatch" + ) # ------- UTILITIES ------- # diff --git a/autogpt_platform/backend/backend/integrations/webhooks/graph_lifecycle_hooks.py b/autogpt_platform/backend/backend/integrations/webhooks/graph_lifecycle_hooks.py index 0d44a51e1..ef3bad02a 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/graph_lifecycle_hooks.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/graph_lifecycle_hooks.py @@ -1,9 +1,8 @@ import logging from typing import TYPE_CHECKING, Callable, Optional, cast -from backend.data.block import BlockWebhookConfig, get_block +from backend.data.block import BlockSchema, BlockWebhookConfig, get_block from backend.data.graph import set_node_webhook -from backend.data.model import CREDENTIALS_FIELD_NAME from backend.integrations.webhooks import WEBHOOK_MANAGERS_BY_NAME if TYPE_CHECKING: @@ -30,14 +29,28 @@ async def on_graph_activate( # Compare nodes in new_graph_version with previous_graph_version updated_nodes = [] for new_node in graph.nodes: + block = get_block(new_node.block_id) + if not block: + raise ValueError( + f"Node #{new_node.id} is instance of unknown block #{new_node.block_id}" + ) + block_input_schema = cast(BlockSchema, block.input_schema) + node_credentials = None - if creds_meta := new_node.input_default.get(CREDENTIALS_FIELD_NAME): - node_credentials = get_credentials(creds_meta["id"]) - if not node_credentials: - raise ValueError( - f"Node #{new_node.id} updated with non-existent " - f"credentials #{node_credentials}" + if ( + # Webhook-triggered blocks are only allowed to have 1 credentials input + ( + creds_field_name := next( + iter(block_input_schema.get_credentials_fields()), None ) + ) + and (creds_meta := new_node.input_default.get(creds_field_name)) + and not (node_credentials := get_credentials(creds_meta["id"])) + ): + raise ValueError( + f"Node #{new_node.id} input '{creds_field_name}' updated with " + f"non-existent credentials #{creds_meta['id']}" + ) updated_node = await on_node_activate( graph.user_id, new_node, credentials=node_credentials @@ -62,14 +75,28 @@ async def on_graph_deactivate( """ updated_nodes = [] for node in graph.nodes: + block = get_block(node.block_id) + if not block: + raise ValueError( + f"Node #{node.id} is instance of unknown block #{node.block_id}" + ) + block_input_schema = cast(BlockSchema, block.input_schema) + node_credentials = None - if creds_meta := node.input_default.get(CREDENTIALS_FIELD_NAME): - node_credentials = get_credentials(creds_meta["id"]) - if not node_credentials: - logger.error( - f"Node #{node.id} referenced non-existent " - f"credentials #{creds_meta['id']}" + if ( + # Webhook-triggered blocks are only allowed to have 1 credentials input + ( + creds_field_name := next( + iter(block_input_schema.get_credentials_fields()), None ) + ) + and (creds_meta := node.input_default.get(creds_field_name)) + and not (node_credentials := get_credentials(creds_meta["id"])) + ): + logger.error( + f"Node #{node.id} input '{creds_field_name}' referenced non-existent " + f"credentials #{creds_meta['id']}" + ) updated_node = await on_node_deactivate(node, credentials=node_credentials) updated_nodes.append(updated_node) @@ -119,14 +146,17 @@ async def on_node_activate( else: resource = "" # not relevant for manual webhooks - needs_credentials = CREDENTIALS_FIELD_NAME in block.input_schema.model_fields + block_input_schema = cast(BlockSchema, block.input_schema) + credentials_field_name = next(iter(block_input_schema.get_credentials_fields()), "") credentials_meta = ( - node.input_default.get(CREDENTIALS_FIELD_NAME) if needs_credentials else None + node.input_default.get(credentials_field_name) + if credentials_field_name + else None ) event_filter_input_name = block.webhook_config.event_filter_input has_everything_for_webhook = ( resource is not None - and (credentials_meta or not needs_credentials) + and (credentials_meta or not credentials_field_name) and ( not event_filter_input_name or ( @@ -230,7 +260,7 @@ async def on_node_deactivate( ) await webhooks_manager.prune_webhook_if_dangling(webhook.id, credentials) if ( - CREDENTIALS_FIELD_NAME in block.input_schema.model_fields + cast(BlockSchema, block.input_schema).get_credentials_fields() and not credentials ): logger.warning( diff --git a/autogpt_platform/backend/backend/server/v2/store/db.py b/autogpt_platform/backend/backend/server/v2/store/db.py index 76206768e..deed4e158 100644 --- a/autogpt_platform/backend/backend/server/v2/store/db.py +++ b/autogpt_platform/backend/backend/server/v2/store/db.py @@ -1,14 +1,18 @@ import logging import random from datetime import datetime +from typing import Optional +import fastapi import prisma.enums import prisma.errors import prisma.models import prisma.types +import backend.data.graph import backend.server.v2.store.exceptions import backend.server.v2.store.model +from backend.data.graph import GraphModel logger = logging.getLogger(__name__) @@ -786,3 +790,45 @@ async def get_my_agents( raise backend.server.v2.store.exceptions.DatabaseError( "Failed to fetch my agents" ) from e + + +async def get_agent( + store_listing_version_id: str, version_id: Optional[int] +) -> GraphModel: + """Get agent using the version ID and store listing version ID.""" + try: + store_listing_version = ( + await prisma.models.StoreListingVersion.prisma().find_unique( + where={"id": store_listing_version_id}, include={"Agent": True} + ) + ) + + if not store_listing_version or not store_listing_version.Agent: + raise fastapi.HTTPException( + status_code=404, + detail=f"Store listing version {store_listing_version_id} not found", + ) + + agent = store_listing_version.Agent + + graph = await backend.data.graph.get_graph( + agent.id, agent.version, template=True + ) + + if not graph: + raise fastapi.HTTPException( + status_code=404, detail=f"Agent {agent.id} not found" + ) + + graph.version = 1 + graph.is_template = False + graph.is_active = True + delattr(graph, "user_id") + + return graph + + except Exception as e: + logger.error(f"Error getting agent: {str(e)}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to fetch agent" + ) from e diff --git a/autogpt_platform/backend/backend/server/v2/store/routes.py b/autogpt_platform/backend/backend/server/v2/store/routes.py index 6aa264ca0..6dc9d7594 100644 --- a/autogpt_platform/backend/backend/server/v2/store/routes.py +++ b/autogpt_platform/backend/backend/server/v2/store/routes.py @@ -1,4 +1,6 @@ +import json import logging +import tempfile import typing import urllib.parse @@ -6,7 +8,9 @@ import autogpt_libs.auth.depends import autogpt_libs.auth.middleware import fastapi import fastapi.responses +from fastapi.encoders import jsonable_encoder +import backend.data.block import backend.data.graph import backend.server.v2.store.db import backend.server.v2.store.image_gen @@ -575,3 +579,66 @@ async def generate_image( status_code=500, content={"detail": "An error occurred while generating the image"}, ) + + +@router.get( + "/download/agents/{store_listing_version_id}", + tags=["store", "public"], +) +async def download_agent_file( + store_listing_version_id: str = fastapi.Path( + ..., description="The ID of the agent to download" + ), + version: typing.Optional[int] = fastapi.Query( + None, description="Specific version of the agent" + ), +) -> fastapi.responses.FileResponse: + """ + Download the agent file by streaming its content. + + Args: + agent_id (str): The ID of the agent to download. + version (Optional[int]): Specific version of the agent to download. + + Returns: + StreamingResponse: A streaming response containing the agent's graph data. + + Raises: + HTTPException: If the agent is not found or an unexpected error occurs. + """ + + graph_data = await backend.server.v2.store.db.get_agent( + store_listing_version_id=store_listing_version_id, version_id=version + ) + + graph_data.clean_graph() + graph_date_dict = jsonable_encoder(graph_data) + + def remove_credentials(obj): + if obj and isinstance(obj, dict): + if "credentials" in obj: + del obj["credentials"] + if "creds" in obj: + del obj["creds"] + + for value in obj.values(): + remove_credentials(value) + elif isinstance(obj, list): + for item in obj: + remove_credentials(item) + return obj + + graph_date_dict = remove_credentials(graph_date_dict) + + file_name = f"agent_{store_listing_version_id}_v{version or 'latest'}.json" + + # Sending graph as a stream (similar to marketplace v1) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as tmp_file: + tmp_file.write(json.dumps(graph_date_dict)) + tmp_file.flush() + + return fastapi.responses.FileResponse( + tmp_file.name, filename=file_name, media_type="application/json" + ) diff --git a/autogpt_platform/backend/backend/util/request.py b/autogpt_platform/backend/backend/util/request.py index 10b716f2c..9a3fa0c5e 100644 --- a/autogpt_platform/backend/backend/util/request.py +++ b/autogpt_platform/backend/backend/util/request.py @@ -33,20 +33,6 @@ ALLOWED_SCHEMES = ["http", "https"] HOSTNAME_REGEX = re.compile(r"^[A-Za-z0-9.-]+$") # Basic DNS-safe hostname pattern -def _canonicalize_url(url: str) -> str: - """ - Normalizes the URL by: - 1. Stripping whitespace and trailing slashes. - 2. Ensuring the scheme is http:// or https:// if missing. - 3. Replacing backslashes with forward slashes. - """ - url = url.strip().strip("/") - if not url.startswith(("http://", "https://")): - url = "http://" + url - url = url.replace("\\", "/") - return url - - def _is_ip_blocked(ip: str) -> bool: """ Checks if the IP address is in a blocked network. @@ -61,9 +47,12 @@ def validate_url(url: str, trusted_origins: list[str]) -> str: to a private, link-local, or otherwise blocked IP address — unless the hostname is explicitly trusted. """ - # Normalize/canonicalize input - url = _canonicalize_url(url) + # Canonicalize URL + url = url.strip("/ ").replace("\\", "/") parsed = urlparse(url) + if not parsed.scheme: + url = f"http://{url}" + parsed = urlparse(url) # Check scheme if parsed.scheme not in ALLOWED_SCHEMES: diff --git a/autogpt_platform/backend/backend/util/test.py b/autogpt_platform/backend/backend/util/test.py index 5433fd902..a471ce681 100644 --- a/autogpt_platform/backend/backend/util/test.py +++ b/autogpt_platform/backend/backend/util/test.py @@ -1,11 +1,11 @@ import logging import time -from typing import Sequence +from typing import Sequence, cast from backend.data import db -from backend.data.block import Block, initialize_blocks +from backend.data.block import Block, BlockSchema, initialize_blocks from backend.data.execution import ExecutionResult, ExecutionStatus -from backend.data.model import CREDENTIALS_FIELD_NAME +from backend.data.model import _BaseCredentials from backend.data.user import create_default_user from backend.executor import DatabaseManager, ExecutionManager, ExecutionScheduler from backend.server.rest_api import AgentServer @@ -100,14 +100,22 @@ def execute_block_test(block: Block): else: log.info(f"{prefix} mock {mock_name} not found in block") + # Populate credentials argument(s) extra_exec_kwargs = {} - - if CREDENTIALS_FIELD_NAME in block.input_schema.model_fields: - if not block.test_credentials: - raise ValueError( - f"{prefix} requires credentials but has no test_credentials" - ) - extra_exec_kwargs[CREDENTIALS_FIELD_NAME] = block.test_credentials + input_model = cast(type[BlockSchema], block.input_schema) + credentials_input_fields = input_model.get_credentials_fields() + if len(credentials_input_fields) == 1 and isinstance( + block.test_credentials, _BaseCredentials + ): + field_name = next(iter(credentials_input_fields)) + extra_exec_kwargs[field_name] = block.test_credentials + elif credentials_input_fields and block.test_credentials: + if not isinstance(block.test_credentials, dict): + raise TypeError(f"Block {block.name} has no usable test credentials") + else: + for field_name in credentials_input_fields: + if field_name in block.test_credentials: + extra_exec_kwargs[field_name] = block.test_credentials[field_name] for input_data in block.test_input: log.info(f"{prefix} in: {input_data}") diff --git a/autogpt_platform/backend/test/data/test_graph.py b/autogpt_platform/backend/test/data/test_graph.py index 0075fde9d..ddff6f3ad 100644 --- a/autogpt_platform/backend/test/data/test_graph.py +++ b/autogpt_platform/backend/test/data/test_graph.py @@ -160,3 +160,45 @@ async def test_get_input_schema(server: SpinTestServer): output_schema = created_graph.output_schema output_schema["title"] = "ExpectedOutputSchema" assert output_schema == ExpectedOutputSchema.jsonschema() + + +@pytest.mark.asyncio(scope="session") +async def test_clean_graph(server: SpinTestServer): + """ + Test the clean_graph function that: + 1. Clears input block values + 2. Removes credentials from nodes + """ + # Create a graph with input blocks and credentials + graph = Graph( + id="test_clean_graph", + name="Test Clean Graph", + description="Test graph cleaning", + nodes=[ + Node( + id="input_node", + block_id=AgentInputBlock().id, + input_default={ + "name": "test_input", + "value": "test value", + "description": "Test input description", + }, + ), + ], + links=[], + ) + + # Create graph and get model + create_graph = CreateGraph(graph=graph) + created_graph = await server.agent_server.test_create_graph( + create_graph, DEFAULT_USER_ID + ) + + # Clean the graph + created_graph.clean_graph() + + # # Verify input block value is cleared + input_node = next( + n for n in created_graph.nodes if n.block_id == AgentInputBlock().id + ) + assert input_node.input_default["value"] == "" diff --git a/autogpt_platform/frontend/src/components/CustomNode.tsx b/autogpt_platform/frontend/src/components/CustomNode.tsx index 735abe555..2ff382879 100644 --- a/autogpt_platform/frontend/src/components/CustomNode.tsx +++ b/autogpt_platform/frontend/src/components/CustomNode.tsx @@ -245,6 +245,7 @@ export function CustomNode({ ].includes(nodeType) && // No input connection handles for credentials propKey !== "credentials" && + !propKey.endsWith("_credentials") && // For OUTPUT blocks, only show the 'value' (hides 'name') input connection handle !(nodeType == BlockUIType.OUTPUT && propKey == "name"); const isConnected = isInputHandleConnected(propKey); @@ -261,7 +262,8 @@ export function CustomNode({ side="left" /> ) : ( - propKey != "credentials" && ( + propKey !== "credentials" && + !propKey.endsWith("_credentials") && (
{propSchema.title || beautifyString(propKey)} @@ -726,13 +728,10 @@ export function CustomNode({
{/* Body */} -
+
{/* Input Handles */} {data.uiType !== BlockUIType.NOTE ? ( -
+
{data.uiType === BlockUIType.WEBHOOK_MANUAL && (data.webhook ? ( @@ -781,7 +780,6 @@ export function CustomNode({
@@ -790,7 +788,7 @@ export function CustomNode({ {data.uiType !== BlockUIType.NOTE && ( <> -
+
{data.outputSchema && generateOutputHandles(data.outputSchema, data.uiType)} diff --git a/autogpt_platform/frontend/src/components/NodeHandle.tsx b/autogpt_platform/frontend/src/components/NodeHandle.tsx index 137e5e4c3..f733c1763 100644 --- a/autogpt_platform/frontend/src/components/NodeHandle.tsx +++ b/autogpt_platform/frontend/src/components/NodeHandle.tsx @@ -82,7 +82,7 @@ const NodeHandle: FC = ({ data-testid={`output-handle-${keyName}`} position={Position.Right} id={keyName} - className="group -mr-[26px]" + className="group -mr-[38px]" >
{label} diff --git a/autogpt_platform/frontend/src/components/agptui/AgentInfo.tsx b/autogpt_platform/frontend/src/components/agptui/AgentInfo.tsx index fe12e75b4..03f7d141d 100644 --- a/autogpt_platform/frontend/src/components/agptui/AgentInfo.tsx +++ b/autogpt_platform/frontend/src/components/agptui/AgentInfo.tsx @@ -6,6 +6,10 @@ import { Separator } from "@/components/ui/separator"; import BackendAPI from "@/lib/autogpt-server-api"; import { useRouter } from "next/navigation"; import Link from "next/link"; +import { useToast } from "@/components/ui/use-toast"; + +import useSupabase from "@/hooks/useSupabase"; +import { DownloadIcon, LoaderIcon } from "lucide-react"; interface AgentInfoProps { name: string; creator: string; @@ -32,8 +36,11 @@ export const AgentInfo: React.FC = ({ storeListingVersionId, }) => { const router = useRouter(); - const api = React.useMemo(() => new BackendAPI(), []); + const { user } = useSupabase(); + const { toast } = useToast(); + + const [downloading, setDownloading] = React.useState(false); const handleAddToLibrary = async () => { try { @@ -45,6 +52,46 @@ export const AgentInfo: React.FC = ({ } }; + const handleDownloadToLibrary = async () => { + const downloadAgent = async (): Promise => { + setDownloading(true); + try { + const file = await api.downloadStoreAgent(storeListingVersionId); + + // Similar to Marketplace v1 + const jsonData = JSON.stringify(file, null, 2); + // Create a Blob from the file content + const blob = new Blob([jsonData], { type: "application/json" }); + + // Create a temporary URL for the Blob + const url = window.URL.createObjectURL(blob); + + // Create a temporary anchor element + const a = document.createElement("a"); + a.href = url; + a.download = `agent_${storeListingVersionId}.json`; // Set the filename + + // Append the anchor to the body, click it, and remove it + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + // Revoke the temporary URL + window.URL.revokeObjectURL(url); + + toast({ + title: "Download Complete", + description: "Your agent has been successfully downloaded.", + }); + } catch (error) { + console.error(`Error downloading agent:`, error); + throw error; + } + }; + await downloadAgent(); + setDownloading(false); + }; + return (
{/* Title */} @@ -72,15 +119,36 @@ export const AgentInfo: React.FC = ({ {/* Run Agent Button */}
- + {user ? ( + + ) : ( + + )}
{/* Rating and Runs */} diff --git a/autogpt_platform/frontend/src/components/customnode.css b/autogpt_platform/frontend/src/components/customnode.css index d947540f4..8e4ed0c87 100644 --- a/autogpt_platform/frontend/src/components/customnode.css +++ b/autogpt_platform/frontend/src/components/customnode.css @@ -15,15 +15,11 @@ .custom-node [data-id^="date-picker"], .custom-node [data-list-container], .custom-node [data-add-item], -.custom-node [data-content-settings] { - min-width: calc(100% - 2.5rem); - max-width: 400px; -} - -.array-item-container { +.custom-node [data-content-settings]. .array-item-container { display: flex; align-items: center; min-width: calc(100% - 2.5rem); + max-width: 100%; } .custom-node .custom-switch { diff --git a/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx b/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx index afd5949a5..a7ad42f79 100644 --- a/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx +++ b/autogpt_platform/frontend/src/components/integrations/credentials-input.tsx @@ -1,5 +1,5 @@ import { z } from "zod"; -import { cn } from "@/lib/utils"; +import { beautifyString, cn } from "@/lib/utils"; import { useForm } from "react-hook-form"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -87,12 +87,13 @@ export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & ( ); export const CredentialsInput: FC<{ + selfKey: string; className?: string; selectedCredentials?: CredentialsMetaInput; onSelectCredentials: (newValue?: CredentialsMetaInput) => void; -}> = ({ className, selectedCredentials, onSelectCredentials }) => { +}> = ({ selfKey, className, selectedCredentials, onSelectCredentials }) => { const api = useBackendAPI(); - const credentials = useCredentials(); + const credentials = useCredentials(selfKey); const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] = useState(false); const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false); @@ -209,6 +210,7 @@ export const CredentialsInput: FC<{ <> {supportsApiKey && ( setAPICredentialsModalOpen(false)} onCredentialsCreate={(credsMeta) => { @@ -242,7 +244,9 @@ export const CredentialsInput: FC<{ return ( <>
- Credentials + + {providerName} Credentials +
@@ -310,7 +314,12 @@ export const CredentialsInput: FC<{ // Saved credentials exist return ( <> - Credentials +
+ + {providerName} Credentials + + +