mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
refactor(platform): rename 'competitor' to neutral wording + add defensive guards
- Rename CompetitorFormat → SourcePlatform across all backend/frontend code - Rename convert_competitor_workflow → convert_workflow - Rename import_competitor_workflow → import_workflow - Change API path from /competitor-workflow to /workflow - Rename frontend LibraryImportCompetitorDialog → LibraryImportWorkflowDialog - Update all docstrings, comments, and UI text to neutral language - Add defensive type guards in describers: isinstance checks for n8n nodes, Make.com modules, Zapier steps, and connection entries - Add str() cast for operation/resource in _describe_n8n_action - Add empty workflow rejection in describe_workflow router - Regenerate API client for new endpoint path
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""API endpoint for importing competitor workflows."""
|
||||
"""API endpoint for importing external workflows."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
@@ -7,10 +7,10 @@ import pydantic
|
||||
from autogpt_libs.auth import get_user_id, requires_user
|
||||
from fastapi import APIRouter, HTTPException, Security
|
||||
|
||||
from backend.copilot.workflow_import.converter import convert_competitor_workflow
|
||||
from backend.copilot.workflow_import.converter import convert_workflow
|
||||
from backend.copilot.workflow_import.describers import describe_workflow
|
||||
from backend.copilot.workflow_import.format_detector import (
|
||||
CompetitorFormat,
|
||||
SourcePlatform,
|
||||
detect_format,
|
||||
)
|
||||
from backend.copilot.workflow_import.url_fetcher import fetch_n8n_template
|
||||
@@ -21,7 +21,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
class ImportWorkflowRequest(pydantic.BaseModel):
|
||||
"""Request body for importing a competitor workflow."""
|
||||
"""Request body for importing an external workflow."""
|
||||
|
||||
workflow_json: dict[str, Any] | None = None
|
||||
template_url: str | None = None
|
||||
@@ -41,7 +41,7 @@ class ImportWorkflowRequest(pydantic.BaseModel):
|
||||
|
||||
|
||||
class ImportWorkflowResponse(pydantic.BaseModel):
|
||||
"""Response from importing a competitor workflow."""
|
||||
"""Response from importing an external workflow."""
|
||||
|
||||
graph: dict[str, Any]
|
||||
graph_id: str | None = None
|
||||
@@ -52,16 +52,17 @@ class ImportWorkflowResponse(pydantic.BaseModel):
|
||||
|
||||
|
||||
@router.post(
|
||||
path="/competitor-workflow",
|
||||
summary="Import a competitor workflow (n8n, Make.com, Zapier)",
|
||||
path="/workflow",
|
||||
summary="Import a workflow from another tool (n8n, Make.com, Zapier)",
|
||||
tags=["import"],
|
||||
dependencies=[Security(requires_user)],
|
||||
)
|
||||
async def import_competitor_workflow(
|
||||
async def import_workflow(
|
||||
request: ImportWorkflowRequest,
|
||||
user_id: Annotated[str, Security(get_user_id)],
|
||||
) -> ImportWorkflowResponse:
|
||||
"""Import a workflow from a competitor platform and convert it to an AutoGPT agent.
|
||||
"""Import a workflow from another automation platform and convert it to an
|
||||
AutoGPT agent.
|
||||
|
||||
Accepts either raw workflow JSON or a template URL (n8n only for now).
|
||||
The workflow is parsed, described, and then converted to an AutoGPT graph
|
||||
@@ -81,7 +82,7 @@ async def import_competitor_workflow(
|
||||
|
||||
# Step 2: Detect format
|
||||
fmt = detect_format(workflow_json)
|
||||
if fmt == CompetitorFormat.UNKNOWN:
|
||||
if fmt == SourcePlatform.UNKNOWN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Could not detect workflow format. Supported formats: "
|
||||
@@ -94,7 +95,7 @@ async def import_competitor_workflow(
|
||||
|
||||
# Step 4: Convert to AutoGPT agent
|
||||
try:
|
||||
agent_json, conversion_notes = await convert_competitor_workflow(desc)
|
||||
agent_json, conversion_notes = await convert_workflow(desc)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
|
||||
@@ -75,7 +75,7 @@ def mock_converter(mocker):
|
||||
"links": [],
|
||||
}
|
||||
return mocker.patch(
|
||||
"backend.api.features.workflow_import.convert_competitor_workflow",
|
||||
"backend.api.features.workflow_import.convert_workflow",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(agent_json, ["Applied 2 auto-fixes"]),
|
||||
)
|
||||
@@ -96,10 +96,10 @@ def mock_save(mocker):
|
||||
)
|
||||
|
||||
|
||||
class TestImportCompetitorWorkflow:
|
||||
class TestImportWorkflow:
|
||||
def test_import_n8n_workflow(self, mock_converter, mock_save):
|
||||
response = client.post(
|
||||
"/competitor-workflow",
|
||||
"/workflow",
|
||||
json={"workflow_json": N8N_WORKFLOW, "save": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -111,7 +111,7 @@ class TestImportCompetitorWorkflow:
|
||||
|
||||
def test_import_make_workflow(self, mock_converter, mock_save):
|
||||
response = client.post(
|
||||
"/competitor-workflow",
|
||||
"/workflow",
|
||||
json={"workflow_json": MAKE_WORKFLOW, "save": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -121,7 +121,7 @@ class TestImportCompetitorWorkflow:
|
||||
|
||||
def test_import_zapier_workflow(self, mock_converter, mock_save):
|
||||
response = client.post(
|
||||
"/competitor-workflow",
|
||||
"/workflow",
|
||||
json={"workflow_json": ZAPIER_WORKFLOW, "save": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -131,7 +131,7 @@ class TestImportCompetitorWorkflow:
|
||||
|
||||
def test_import_without_save(self, mock_converter, mock_save):
|
||||
response = client.post(
|
||||
"/competitor-workflow",
|
||||
"/workflow",
|
||||
json={"workflow_json": N8N_WORKFLOW, "save": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -142,14 +142,14 @@ class TestImportCompetitorWorkflow:
|
||||
|
||||
def test_no_source_provided(self):
|
||||
response = client.post(
|
||||
"/competitor-workflow",
|
||||
"/workflow",
|
||||
json={"save": True},
|
||||
)
|
||||
assert response.status_code == 422 # Pydantic validation error
|
||||
|
||||
def test_both_sources_provided(self):
|
||||
response = client.post(
|
||||
"/competitor-workflow",
|
||||
"/workflow",
|
||||
json={
|
||||
"workflow_json": N8N_WORKFLOW,
|
||||
"template_url": "https://n8n.io/workflows/123",
|
||||
@@ -160,7 +160,7 @@ class TestImportCompetitorWorkflow:
|
||||
|
||||
def test_unknown_format_returns_400(self, mock_converter):
|
||||
response = client.post(
|
||||
"/competitor-workflow",
|
||||
"/workflow",
|
||||
json={"workflow_json": {"foo": "bar"}, "save": False},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -168,12 +168,12 @@ class TestImportCompetitorWorkflow:
|
||||
|
||||
def test_converter_failure_returns_502(self, mocker):
|
||||
mocker.patch(
|
||||
"backend.api.features.workflow_import.convert_competitor_workflow",
|
||||
"backend.api.features.workflow_import.convert_workflow",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=ValueError("LLM call failed"),
|
||||
)
|
||||
response = client.post(
|
||||
"/competitor-workflow",
|
||||
"/workflow",
|
||||
json={"workflow_json": N8N_WORKFLOW, "save": False},
|
||||
)
|
||||
assert response.status_code == 502
|
||||
@@ -186,7 +186,7 @@ class TestImportCompetitorWorkflow:
|
||||
side_effect=RuntimeError("DB connection failed"),
|
||||
)
|
||||
response = client.post(
|
||||
"/competitor-workflow",
|
||||
"/workflow",
|
||||
json={"workflow_json": N8N_WORKFLOW, "save": True},
|
||||
)
|
||||
assert response.status_code == 500
|
||||
@@ -199,7 +199,7 @@ class TestImportCompetitorWorkflow:
|
||||
side_effect=ValueError("Invalid URL format"),
|
||||
)
|
||||
response = client.post(
|
||||
"/competitor-workflow",
|
||||
"/workflow",
|
||||
json={"template_url": "https://bad-url.com", "save": False},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -212,7 +212,7 @@ class TestImportCompetitorWorkflow:
|
||||
side_effect=RuntimeError("n8n API returned 500"),
|
||||
)
|
||||
response = client.post(
|
||||
"/competitor-workflow",
|
||||
"/workflow",
|
||||
json={"template_url": "https://n8n.io/workflows/123", "save": False},
|
||||
)
|
||||
assert response.status_code == 502
|
||||
@@ -220,7 +220,7 @@ class TestImportCompetitorWorkflow:
|
||||
|
||||
def test_response_model_shape(self, mock_converter, mock_save):
|
||||
response = client.post(
|
||||
"/competitor-workflow",
|
||||
"/workflow",
|
||||
json={"workflow_json": N8N_WORKFLOW, "save": True},
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Competitor workflow import module.
|
||||
"""Workflow import module.
|
||||
|
||||
Converts workflows from n8n, Make.com, and Zapier into AutoGPT agent graphs.
|
||||
"""
|
||||
|
||||
from .converter import convert_competitor_workflow
|
||||
from .format_detector import CompetitorFormat, detect_format
|
||||
from .converter import convert_workflow
|
||||
from .format_detector import SourcePlatform, detect_format
|
||||
from .models import WorkflowDescription
|
||||
|
||||
__all__ = [
|
||||
"CompetitorFormat",
|
||||
"SourcePlatform",
|
||||
"WorkflowDescription",
|
||||
"convert_competitor_workflow",
|
||||
"convert_workflow",
|
||||
"detect_format",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""LLM-powered conversion of competitor workflows to AutoGPT agent graphs.
|
||||
"""LLM-powered conversion of external workflows to AutoGPT agent graphs.
|
||||
|
||||
Uses the CoPilot's LLM client to generate AutoGPT agent JSON from a structured
|
||||
WorkflowDescription, then validates and fixes via the existing pipeline.
|
||||
@@ -84,7 +84,7 @@ def _build_conversion_prompt(
|
||||
|
||||
system_msg = f"""You are an expert at converting automation workflows into AutoGPT agent graphs.
|
||||
|
||||
Your task: Convert the competitor workflow described below into a valid AutoGPT agent JSON.
|
||||
Your task: Convert the workflow described below into a valid AutoGPT agent JSON.
|
||||
|
||||
## Agent Generation Guide
|
||||
{agent_guide}
|
||||
@@ -93,11 +93,11 @@ Your task: Convert the competitor workflow described below into a valid AutoGPT
|
||||
{block_catalog}
|
||||
|
||||
## Instructions
|
||||
1. Map each competitor workflow step to the most appropriate AutoGPT block(s)
|
||||
1. Map each workflow step to the most appropriate AutoGPT block(s)
|
||||
2. If no exact block match exists, use the closest alternative (e.g., HttpRequestBlock for generic API calls)
|
||||
3. Every agent MUST have at least one AgentInputBlock and one AgentOutputBlock
|
||||
4. Wire blocks together with links matching the original workflow's data flow
|
||||
5. Set meaningful input_default values based on the competitor's parameters
|
||||
5. Set meaningful input_default values based on the workflow's parameters
|
||||
6. Position nodes with 800+ X-unit spacing
|
||||
7. Return ONLY valid JSON — no markdown fences, no explanation"""
|
||||
|
||||
@@ -124,13 +124,13 @@ IMPORTANT: Your previous attempt had validation errors. Fix them:
|
||||
]
|
||||
|
||||
|
||||
async def convert_competitor_workflow(
|
||||
async def convert_workflow(
|
||||
desc: WorkflowDescription,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Convert a WorkflowDescription into an AutoGPT agent JSON.
|
||||
|
||||
Args:
|
||||
desc: Structured description of the competitor workflow.
|
||||
desc: Structured description of the source workflow.
|
||||
|
||||
Returns:
|
||||
Tuple of (agent_json dict, conversion_notes list).
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
"""Extract structured WorkflowDescription from competitor workflow JSONs.
|
||||
"""Extract structured WorkflowDescription from external workflow JSONs.
|
||||
|
||||
Each describer is a pure function that deterministically parses the competitor
|
||||
Each describer is a pure function that deterministically parses the source
|
||||
format into a platform-agnostic WorkflowDescription. No LLM calls are made here.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .models import CompetitorFormat, StepDescription, WorkflowDescription
|
||||
from .models import SourcePlatform, StepDescription, WorkflowDescription
|
||||
|
||||
|
||||
def describe_workflow(
|
||||
json_data: dict[str, Any], fmt: CompetitorFormat
|
||||
json_data: dict[str, Any], fmt: SourcePlatform
|
||||
) -> WorkflowDescription:
|
||||
"""Route to the appropriate describer based on detected format."""
|
||||
describers = {
|
||||
CompetitorFormat.N8N: describe_n8n_workflow,
|
||||
CompetitorFormat.MAKE: describe_make_workflow,
|
||||
CompetitorFormat.ZAPIER: describe_zapier_workflow,
|
||||
SourcePlatform.N8N: describe_n8n_workflow,
|
||||
SourcePlatform.MAKE: describe_make_workflow,
|
||||
SourcePlatform.ZAPIER: describe_zapier_workflow,
|
||||
}
|
||||
describer = describers.get(fmt)
|
||||
if not describer:
|
||||
raise ValueError(f"No describer available for format: {fmt}")
|
||||
return describer(json_data)
|
||||
result = describer(json_data)
|
||||
if not result.steps:
|
||||
raise ValueError(f"Workflow contains no steps (format: {fmt.value})")
|
||||
return result
|
||||
|
||||
|
||||
def describe_n8n_workflow(json_data: dict[str, Any]) -> WorkflowDescription:
|
||||
@@ -35,8 +38,11 @@ def describe_n8n_workflow(json_data: dict[str, Any]) -> WorkflowDescription:
|
||||
steps: list[StepDescription] = []
|
||||
|
||||
for i, node in enumerate(nodes):
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
node_name = node.get("name", f"Node {i}")
|
||||
node_index[node_name] = i
|
||||
node_index[node_name] = len(steps)
|
||||
|
||||
node_type = node.get("type", "unknown")
|
||||
# Extract service name from type (e.g., "n8n-nodes-base.gmail" -> "Gmail")
|
||||
@@ -44,6 +50,8 @@ def describe_n8n_workflow(json_data: dict[str, Any]) -> WorkflowDescription:
|
||||
|
||||
# Build action description from type and parameters
|
||||
params = node.get("parameters", {})
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
action = _describe_n8n_action(node_type, node_name, params)
|
||||
|
||||
# Extract key parameters (skip large/internal ones)
|
||||
@@ -51,7 +59,7 @@ def describe_n8n_workflow(json_data: dict[str, Any]) -> WorkflowDescription:
|
||||
|
||||
steps.append(
|
||||
StepDescription(
|
||||
order=i,
|
||||
order=len(steps),
|
||||
action=action,
|
||||
service=service,
|
||||
parameters=clean_params,
|
||||
@@ -69,16 +77,22 @@ def describe_n8n_workflow(json_data: dict[str, Any]) -> WorkflowDescription:
|
||||
if not isinstance(output_group, list):
|
||||
continue
|
||||
for conn in output_group:
|
||||
if not isinstance(conn, dict):
|
||||
continue
|
||||
target_name = conn.get("node")
|
||||
if not isinstance(target_name, str):
|
||||
continue
|
||||
target_idx = node_index.get(target_name)
|
||||
if target_idx is not None:
|
||||
steps[source_idx].connections_to.append(target_idx)
|
||||
|
||||
# Detect trigger type
|
||||
trigger_type = None
|
||||
if nodes:
|
||||
if nodes and isinstance(nodes[0], dict):
|
||||
first_type = nodes[0].get("type", "")
|
||||
if "trigger" in first_type.lower() or "webhook" in first_type.lower():
|
||||
if isinstance(first_type, str) and (
|
||||
"trigger" in first_type.lower() or "webhook" in first_type.lower()
|
||||
):
|
||||
trigger_type = _extract_n8n_service(first_type)
|
||||
|
||||
return WorkflowDescription(
|
||||
@@ -86,7 +100,7 @@ def describe_n8n_workflow(json_data: dict[str, Any]) -> WorkflowDescription:
|
||||
description=_build_workflow_summary(steps),
|
||||
steps=steps,
|
||||
trigger_type=trigger_type,
|
||||
source_format=CompetitorFormat.N8N,
|
||||
source_format=SourcePlatform.N8N,
|
||||
raw_json=json_data,
|
||||
)
|
||||
|
||||
@@ -95,15 +109,21 @@ def describe_make_workflow(json_data: dict[str, Any]) -> WorkflowDescription:
|
||||
"""Extract a structured description from a Make.com scenario blueprint."""
|
||||
flow = json_data.get("flow", [])
|
||||
steps: list[StepDescription] = []
|
||||
valid_count = 0
|
||||
|
||||
for module in flow:
|
||||
if not isinstance(module, dict):
|
||||
continue
|
||||
|
||||
for i, module in enumerate(flow):
|
||||
module_ref = module.get("module", "unknown:unknown")
|
||||
if not isinstance(module_ref, str):
|
||||
module_ref = "unknown:unknown"
|
||||
parts = module_ref.split(":", 1)
|
||||
service = parts[0].replace("-", " ").title() if parts else "Unknown"
|
||||
action_verb = parts[1] if len(parts) > 1 else "process"
|
||||
|
||||
# Build human-readable action
|
||||
action = f"{action_verb.replace(':', ' ').title()} via {service}"
|
||||
action = f"{str(action_verb).replace(':', ' ').title()} via {service}"
|
||||
|
||||
params = module.get("mapper", module.get("parameters", {}))
|
||||
clean_params = _clean_params(params) if isinstance(params, dict) else {}
|
||||
@@ -116,23 +136,26 @@ def describe_make_workflow(json_data: dict[str, Any]) -> WorkflowDescription:
|
||||
clean_params["_has_routes"] = len(routes)
|
||||
else:
|
||||
# Make.com flows are sequential by default; each step connects to next
|
||||
connections_to = [i + 1] if i < len(flow) - 1 else []
|
||||
connections_to = [valid_count + 1] if valid_count < len(flow) - 1 else []
|
||||
|
||||
steps.append(
|
||||
StepDescription(
|
||||
order=i,
|
||||
order=valid_count,
|
||||
action=action,
|
||||
service=service,
|
||||
parameters=clean_params,
|
||||
connections_to=connections_to,
|
||||
)
|
||||
)
|
||||
valid_count += 1
|
||||
|
||||
# Detect trigger
|
||||
trigger_type = None
|
||||
if flow:
|
||||
if flow and isinstance(flow[0], dict):
|
||||
first_module = flow[0].get("module", "")
|
||||
if "watch" in first_module.lower() or "trigger" in first_module.lower():
|
||||
if isinstance(first_module, str) and (
|
||||
"watch" in first_module.lower() or "trigger" in first_module.lower()
|
||||
):
|
||||
trigger_type = first_module.split(":")[0].replace("-", " ").title()
|
||||
|
||||
return WorkflowDescription(
|
||||
@@ -140,7 +163,7 @@ def describe_make_workflow(json_data: dict[str, Any]) -> WorkflowDescription:
|
||||
description=_build_workflow_summary(steps),
|
||||
steps=steps,
|
||||
trigger_type=trigger_type,
|
||||
source_format=CompetitorFormat.MAKE,
|
||||
source_format=SourcePlatform.MAKE,
|
||||
raw_json=json_data,
|
||||
)
|
||||
|
||||
@@ -149,30 +172,35 @@ def describe_zapier_workflow(json_data: dict[str, Any]) -> WorkflowDescription:
|
||||
"""Extract a structured description from a Zapier Zap JSON."""
|
||||
zap_steps = json_data.get("steps", [])
|
||||
steps: list[StepDescription] = []
|
||||
valid_count = 0
|
||||
|
||||
for step in zap_steps:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
|
||||
for i, step in enumerate(zap_steps):
|
||||
app = step.get("app", "Unknown")
|
||||
action = step.get("action", "process")
|
||||
action_desc = f"{action.replace('_', ' ').title()} via {app}"
|
||||
action_desc = f"{str(action).replace('_', ' ').title()} via {app}"
|
||||
|
||||
params = step.get("params", step.get("inputFields", {}))
|
||||
clean_params = _clean_params(params) if isinstance(params, dict) else {}
|
||||
|
||||
# Zapier zaps are linear: each step connects to next
|
||||
connections_to = [i + 1] if i < len(zap_steps) - 1 else []
|
||||
connections_to = [valid_count + 1] if valid_count < len(zap_steps) - 1 else []
|
||||
|
||||
steps.append(
|
||||
StepDescription(
|
||||
order=i,
|
||||
order=valid_count,
|
||||
action=action_desc,
|
||||
service=app,
|
||||
parameters=clean_params,
|
||||
connections_to=connections_to,
|
||||
)
|
||||
)
|
||||
valid_count += 1
|
||||
|
||||
trigger_type = None
|
||||
if zap_steps:
|
||||
if zap_steps and isinstance(zap_steps[0], dict):
|
||||
trigger_type = zap_steps[0].get("app")
|
||||
|
||||
return WorkflowDescription(
|
||||
@@ -180,7 +208,7 @@ def describe_zapier_workflow(json_data: dict[str, Any]) -> WorkflowDescription:
|
||||
description=_build_workflow_summary(steps),
|
||||
steps=steps,
|
||||
trigger_type=trigger_type,
|
||||
source_format=CompetitorFormat.ZAPIER,
|
||||
source_format=SourcePlatform.ZAPIER,
|
||||
raw_json=json_data,
|
||||
)
|
||||
|
||||
@@ -213,8 +241,8 @@ def _extract_n8n_service(node_type: str) -> str:
|
||||
def _describe_n8n_action(node_type: str, node_name: str, params: dict[str, Any]) -> str:
|
||||
"""Build a human-readable action description for an n8n node."""
|
||||
service = _extract_n8n_service(node_type)
|
||||
resource = params.get("resource", "")
|
||||
operation = params.get("operation", "")
|
||||
resource = str(params.get("resource", ""))
|
||||
operation = str(params.get("operation", ""))
|
||||
|
||||
if resource and operation:
|
||||
return f"{operation.title()} {resource} via {service}"
|
||||
|
||||
@@ -8,7 +8,7 @@ from .describers import (
|
||||
describe_workflow,
|
||||
describe_zapier_workflow,
|
||||
)
|
||||
from .models import CompetitorFormat
|
||||
from .models import SourcePlatform
|
||||
|
||||
|
||||
class TestDescribeN8nWorkflow:
|
||||
@@ -35,7 +35,7 @@ class TestDescribeN8nWorkflow:
|
||||
}
|
||||
desc = describe_n8n_workflow(data)
|
||||
assert desc.name == "Email on Webhook"
|
||||
assert desc.source_format == CompetitorFormat.N8N
|
||||
assert desc.source_format == SourcePlatform.N8N
|
||||
assert len(desc.steps) == 2
|
||||
assert desc.steps[0].connections_to == [1]
|
||||
assert desc.steps[1].connections_to == []
|
||||
@@ -83,7 +83,7 @@ class TestDescribeMakeWorkflow:
|
||||
}
|
||||
desc = describe_make_workflow(data)
|
||||
assert desc.name == "Sheets to Calendar"
|
||||
assert desc.source_format == CompetitorFormat.MAKE
|
||||
assert desc.source_format == SourcePlatform.MAKE
|
||||
assert len(desc.steps) == 2
|
||||
# Sequential: step 0 connects to step 1
|
||||
assert desc.steps[0].connections_to == [1]
|
||||
@@ -113,7 +113,7 @@ class TestDescribeZapierWorkflow:
|
||||
}
|
||||
desc = describe_zapier_workflow(data)
|
||||
assert desc.name == "Gmail to Slack"
|
||||
assert desc.source_format == CompetitorFormat.ZAPIER
|
||||
assert desc.source_format == SourcePlatform.ZAPIER
|
||||
assert len(desc.steps) == 2
|
||||
assert desc.steps[0].connections_to == [1]
|
||||
assert desc.trigger_type == "Gmail"
|
||||
@@ -127,9 +127,9 @@ class TestDescribeWorkflowRouter:
|
||||
],
|
||||
"connections": {},
|
||||
}
|
||||
desc = describe_workflow(data, CompetitorFormat.N8N)
|
||||
assert desc.source_format == CompetitorFormat.N8N
|
||||
desc = describe_workflow(data, SourcePlatform.N8N)
|
||||
assert desc.source_format == SourcePlatform.N8N
|
||||
|
||||
def test_unknown_raises(self):
|
||||
with pytest.raises(ValueError, match="No describer"):
|
||||
describe_workflow({}, CompetitorFormat.UNKNOWN)
|
||||
describe_workflow({}, SourcePlatform.UNKNOWN)
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"""Detect the source platform of a competitor workflow JSON."""
|
||||
"""Detect the source platform of a workflow JSON."""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .models import CompetitorFormat
|
||||
from .models import SourcePlatform
|
||||
|
||||
_N8N_TYPE_RE = re.compile(r"^(n8n-nodes-base\.|@n8n/)")
|
||||
|
||||
|
||||
def detect_format(json_data: dict[str, Any]) -> CompetitorFormat:
|
||||
def detect_format(json_data: dict[str, Any]) -> SourcePlatform:
|
||||
"""Inspect a workflow JSON and determine which platform it came from.
|
||||
|
||||
Args:
|
||||
json_data: The parsed JSON data from a competitor workflow file.
|
||||
json_data: The parsed JSON data from a workflow export file.
|
||||
|
||||
Returns:
|
||||
The detected CompetitorFormat.
|
||||
The detected SourcePlatform.
|
||||
"""
|
||||
if _is_n8n(json_data):
|
||||
return CompetitorFormat.N8N
|
||||
return SourcePlatform.N8N
|
||||
if _is_make(json_data):
|
||||
return CompetitorFormat.MAKE
|
||||
return SourcePlatform.MAKE
|
||||
if _is_zapier(json_data):
|
||||
return CompetitorFormat.ZAPIER
|
||||
return CompetitorFormat.UNKNOWN
|
||||
return SourcePlatform.ZAPIER
|
||||
return SourcePlatform.UNKNOWN
|
||||
|
||||
|
||||
def _is_n8n(data: dict[str, Any]) -> bool:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for format_detector.py."""
|
||||
|
||||
from .format_detector import detect_format
|
||||
from .models import CompetitorFormat
|
||||
from .models import SourcePlatform
|
||||
|
||||
|
||||
class TestDetectFormat:
|
||||
@@ -26,7 +26,7 @@ class TestDetectFormat:
|
||||
}
|
||||
},
|
||||
}
|
||||
assert detect_format(data) == CompetitorFormat.N8N
|
||||
assert detect_format(data) == SourcePlatform.N8N
|
||||
|
||||
def test_n8n_langchain_nodes(self):
|
||||
data = {
|
||||
@@ -39,7 +39,7 @@ class TestDetectFormat:
|
||||
],
|
||||
"connections": {},
|
||||
}
|
||||
assert detect_format(data) == CompetitorFormat.N8N
|
||||
assert detect_format(data) == SourcePlatform.N8N
|
||||
|
||||
def test_make_scenario(self):
|
||||
data = {
|
||||
@@ -55,7 +55,7 @@ class TestDetectFormat:
|
||||
},
|
||||
],
|
||||
}
|
||||
assert detect_format(data) == CompetitorFormat.MAKE
|
||||
assert detect_format(data) == SourcePlatform.MAKE
|
||||
|
||||
def test_zapier_zap(self):
|
||||
data = {
|
||||
@@ -69,14 +69,14 @@ class TestDetectFormat:
|
||||
},
|
||||
],
|
||||
}
|
||||
assert detect_format(data) == CompetitorFormat.ZAPIER
|
||||
assert detect_format(data) == SourcePlatform.ZAPIER
|
||||
|
||||
def test_unknown_format(self):
|
||||
data = {"foo": "bar", "nodes": []}
|
||||
assert detect_format(data) == CompetitorFormat.UNKNOWN
|
||||
assert detect_format(data) == SourcePlatform.UNKNOWN
|
||||
|
||||
def test_empty_dict(self):
|
||||
assert detect_format({}) == CompetitorFormat.UNKNOWN
|
||||
assert detect_format({}) == SourcePlatform.UNKNOWN
|
||||
|
||||
def test_autogpt_graph_not_detected_as_n8n(self):
|
||||
"""AutoGPT graphs have nodes but not n8n-style types."""
|
||||
@@ -86,16 +86,16 @@ class TestDetectFormat:
|
||||
],
|
||||
"connections": {},
|
||||
}
|
||||
assert detect_format(data) == CompetitorFormat.UNKNOWN
|
||||
assert detect_format(data) == SourcePlatform.UNKNOWN
|
||||
|
||||
def test_make_without_colon_not_detected(self):
|
||||
data = {
|
||||
"flow": [{"module": "simplemodule", "mapper": {}}],
|
||||
}
|
||||
assert detect_format(data) == CompetitorFormat.UNKNOWN
|
||||
assert detect_format(data) == SourcePlatform.UNKNOWN
|
||||
|
||||
def test_zapier_without_action_not_detected(self):
|
||||
data = {
|
||||
"steps": [{"app": "gmail"}],
|
||||
}
|
||||
assert detect_format(data) == CompetitorFormat.UNKNOWN
|
||||
assert detect_format(data) == SourcePlatform.UNKNOWN
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Data models for competitor workflow import."""
|
||||
"""Data models for external workflow import."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
@@ -7,7 +7,7 @@ import pydantic
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
class CompetitorFormat(str, Enum):
|
||||
class SourcePlatform(str, Enum):
|
||||
N8N = "n8n"
|
||||
MAKE = "make"
|
||||
ZAPIER = "zapier"
|
||||
@@ -15,7 +15,7 @@ class CompetitorFormat(str, Enum):
|
||||
|
||||
|
||||
class StepDescription(pydantic.BaseModel):
|
||||
"""A single step/node extracted from a competitor workflow."""
|
||||
"""A single step/node extracted from an external workflow."""
|
||||
|
||||
order: int
|
||||
action: str
|
||||
@@ -25,11 +25,11 @@ class StepDescription(pydantic.BaseModel):
|
||||
|
||||
|
||||
class WorkflowDescription(pydantic.BaseModel):
|
||||
"""Structured description of a competitor workflow."""
|
||||
"""Structured description of an external workflow."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
steps: list[StepDescription]
|
||||
trigger_type: str | None = None
|
||||
source_format: CompetitorFormat
|
||||
source_format: SourcePlatform
|
||||
raw_json: dict[str, Any] = Field(default_factory=dict, exclude=True)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Fetch competitor workflow templates by URL."""
|
||||
"""Fetch workflow templates by URL."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import LibraryImportCompetitorDialog from "../LibraryImportCompetitorDialog/LibraryImportCompetitorDialog";
|
||||
import LibraryImportWorkflowDialog from "../LibraryImportWorkflowDialog/LibraryImportWorkflowDialog";
|
||||
import { LibrarySearchBar } from "../LibrarySearchBar/LibrarySearchBar";
|
||||
import LibraryUploadAgentDialog from "../LibraryUploadAgentDialog/LibraryUploadAgentDialog";
|
||||
|
||||
@@ -12,14 +12,14 @@ export function LibraryActionHeader({ setSearchTerm }: Props) {
|
||||
<div className="mb-[32px] hidden items-center justify-center gap-4 md:flex">
|
||||
<LibrarySearchBar setSearchTerm={setSearchTerm} />
|
||||
<LibraryUploadAgentDialog />
|
||||
<LibraryImportCompetitorDialog />
|
||||
<LibraryImportWorkflowDialog />
|
||||
</div>
|
||||
|
||||
{/* Mobile and tablet */}
|
||||
<div className="flex flex-col gap-4 p-4 pt-[52px] md:hidden">
|
||||
<div className="flex w-full justify-between gap-2">
|
||||
<LibraryUploadAgentDialog />
|
||||
<LibraryImportCompetitorDialog />
|
||||
<LibraryImportWorkflowDialog />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
|
||||
@@ -13,14 +13,14 @@ import {
|
||||
} from "@/components/molecules/Form/Form";
|
||||
import { ArrowsClockwiseIcon } from "@phosphor-icons/react";
|
||||
import { z } from "zod";
|
||||
import { useLibraryImportCompetitorDialog } from "./useLibraryImportCompetitorDialog";
|
||||
import { useLibraryImportWorkflowDialog } from "./useLibraryImportWorkflowDialog";
|
||||
|
||||
export const importCompetitorFormSchema = z.object({
|
||||
export const importWorkflowFormSchema = z.object({
|
||||
workflowFile: z.string(),
|
||||
templateUrl: z.string(),
|
||||
});
|
||||
|
||||
export default function LibraryImportCompetitorDialog() {
|
||||
export default function LibraryImportWorkflowDialog() {
|
||||
const {
|
||||
onSubmit,
|
||||
isConverting,
|
||||
@@ -29,7 +29,7 @@ export default function LibraryImportCompetitorDialog() {
|
||||
form,
|
||||
importMode,
|
||||
setImportMode,
|
||||
} = useLibraryImportCompetitorDialog();
|
||||
} = useLibraryImportWorkflowDialog();
|
||||
|
||||
const hasInput =
|
||||
importMode === "url"
|
||||
@@ -38,7 +38,7 @@ export default function LibraryImportCompetitorDialog() {
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Import Competitor Workflow"
|
||||
title="Import Workflow"
|
||||
styling={{ maxWidth: "32rem" }}
|
||||
controlled={{
|
||||
isOpen,
|
||||
@@ -51,7 +51,7 @@ export default function LibraryImportCompetitorDialog() {
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<Button
|
||||
data-testid="import-competitor-button"
|
||||
data-testid="import-workflow-button"
|
||||
variant="primary"
|
||||
className="h-[2.78rem] w-full md:w-[14rem]"
|
||||
size="small"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { usePostV2ImportACompetitorWorkflowN8nMakeComZapier } from "@/app/api/__generated__/endpoints/import/import";
|
||||
import { usePostV2ImportAWorkflowFromAnotherToolN8nMakeComZapier } from "@/app/api/__generated__/endpoints/import/import";
|
||||
import type { ImportWorkflowRequest } from "@/app/api/__generated__/models/importWorkflowRequest";
|
||||
import type { ImportWorkflowResponse } from "@/app/api/__generated__/models/importWorkflowResponse";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
@@ -6,27 +6,25 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { importCompetitorFormSchema } from "./LibraryImportCompetitorDialog";
|
||||
import { importWorkflowFormSchema } from "./LibraryImportWorkflowDialog";
|
||||
|
||||
export function useLibraryImportCompetitorDialog() {
|
||||
export function useLibraryImportWorkflowDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const [importMode, setImportMode] = useState<"file" | "url">("file");
|
||||
|
||||
const { mutateAsync: importWorkflow, isPending: isConverting } =
|
||||
usePostV2ImportACompetitorWorkflowN8nMakeComZapier();
|
||||
usePostV2ImportAWorkflowFromAnotherToolN8nMakeComZapier();
|
||||
|
||||
const form = useForm<z.infer<typeof importCompetitorFormSchema>>({
|
||||
resolver: zodResolver(importCompetitorFormSchema),
|
||||
const form = useForm<z.infer<typeof importWorkflowFormSchema>>({
|
||||
resolver: zodResolver(importWorkflowFormSchema),
|
||||
defaultValues: {
|
||||
workflowFile: "",
|
||||
templateUrl: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (
|
||||
values: z.infer<typeof importCompetitorFormSchema>,
|
||||
) => {
|
||||
const onSubmit = async (values: z.infer<typeof importWorkflowFormSchema>) => {
|
||||
try {
|
||||
let body: ImportWorkflowRequest;
|
||||
|
||||
@@ -2920,12 +2920,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/import/competitor-workflow": {
|
||||
"/api/import/workflow": {
|
||||
"post": {
|
||||
"tags": ["v2", "import", "import"],
|
||||
"summary": "Import a competitor workflow (n8n, Make.com, Zapier)",
|
||||
"description": "Import a workflow from a competitor platform and convert it to an AutoGPT agent.\n\nAccepts either raw workflow JSON or a template URL (n8n only for now).\nThe workflow is parsed, described, and then converted to an AutoGPT graph\nusing LLM-powered block mapping.",
|
||||
"operationId": "postV2Import a competitor workflow (n8n, make.com, zapier)",
|
||||
"summary": "Import a workflow from another tool (n8n, Make.com, Zapier)",
|
||||
"description": "Import a workflow from another automation platform and convert it to an\nAutoGPT agent.\n\nAccepts either raw workflow JSON or a template URL (n8n only for now).\nThe workflow is parsed, described, and then converted to an AutoGPT graph\nusing LLM-powered block mapping.",
|
||||
"operationId": "postV2Import a workflow from another tool (n8n, make.com, zapier)",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -10028,7 +10028,7 @@
|
||||
},
|
||||
"type": "object",
|
||||
"title": "ImportWorkflowRequest",
|
||||
"description": "Request body for importing a competitor workflow."
|
||||
"description": "Request body for importing an external workflow."
|
||||
},
|
||||
"ImportWorkflowResponse": {
|
||||
"properties": {
|
||||
@@ -10057,7 +10057,7 @@
|
||||
"type": "object",
|
||||
"required": ["graph", "source_format", "source_name"],
|
||||
"title": "ImportWorkflowResponse",
|
||||
"description": "Response from importing a competitor workflow."
|
||||
"description": "Response from importing an external workflow."
|
||||
},
|
||||
"InputValidationErrorResponse": {
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user