mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge branch 'dev' into swiftyos/caching-pt2
This commit is contained in:
@@ -37,5 +37,5 @@ class Project(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
priority: int
|
||||
progress: int
|
||||
content: str
|
||||
progress: float
|
||||
content: str | None
|
||||
|
||||
@@ -84,7 +84,11 @@ from backend.util.file import clean_exec_files
|
||||
from backend.util.logging import TruncatedLogger, configure_logging
|
||||
from backend.util.metrics import DiscordChannel
|
||||
from backend.util.process import AppProcess, set_service_name
|
||||
from backend.util.retry import continuous_retry, func_retry
|
||||
from backend.util.retry import (
|
||||
continuous_retry,
|
||||
func_retry,
|
||||
send_rate_limited_discord_alert,
|
||||
)
|
||||
from backend.util.settings import Settings
|
||||
|
||||
from .cluster_lock import ClusterLock
|
||||
@@ -184,6 +188,7 @@ async def execute_node(
|
||||
_input_data.inputs = input_data
|
||||
if nodes_input_masks:
|
||||
_input_data.nodes_input_masks = nodes_input_masks
|
||||
_input_data.user_id = user_id
|
||||
input_data = _input_data.model_dump()
|
||||
data.inputs = input_data
|
||||
|
||||
@@ -979,16 +984,31 @@ class ExecutionProcessor:
|
||||
if isinstance(e, Exception)
|
||||
else Exception(f"{e.__class__.__name__}: {e}")
|
||||
)
|
||||
if not execution_stats.error:
|
||||
execution_stats.error = str(error)
|
||||
|
||||
known_errors = (InsufficientBalanceError, ModerationError)
|
||||
if isinstance(error, known_errors):
|
||||
execution_stats.error = str(error)
|
||||
return ExecutionStatus.FAILED
|
||||
|
||||
execution_status = ExecutionStatus.FAILED
|
||||
log_metadata.exception(
|
||||
f"Failed graph execution {graph_exec.graph_exec_id}: {error}"
|
||||
)
|
||||
|
||||
# Send rate-limited Discord alert for unknown/unexpected errors
|
||||
send_rate_limited_discord_alert(
|
||||
"graph_execution",
|
||||
error,
|
||||
"unknown_error",
|
||||
f"🚨 **Unknown Graph Execution Error**\n"
|
||||
f"User: {graph_exec.user_id}\n"
|
||||
f"Graph ID: {graph_exec.graph_id}\n"
|
||||
f"Execution ID: {graph_exec.graph_exec_id}\n"
|
||||
f"Error Type: {type(error).__name__}\n"
|
||||
f"Error: {str(error)[:200]}{'...' if len(str(error)) > 200 else ''}\n",
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
finally:
|
||||
|
||||
@@ -64,7 +64,7 @@ class LoginResponse(BaseModel):
|
||||
state_token: str
|
||||
|
||||
|
||||
@router.get("/{provider}/login")
|
||||
@router.get("/{provider}/login", summary="Initiate OAuth flow")
|
||||
async def login(
|
||||
provider: Annotated[
|
||||
ProviderName, Path(title="The provider to initiate an OAuth flow for")
|
||||
@@ -102,7 +102,7 @@ class CredentialsMetaResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{provider}/callback")
|
||||
@router.post("/{provider}/callback", summary="Exchange OAuth code for tokens")
|
||||
async def callback(
|
||||
provider: Annotated[
|
||||
ProviderName, Path(title="The target provider for this OAuth exchange")
|
||||
|
||||
@@ -53,16 +53,6 @@ class ProviderResponse(BaseModel):
|
||||
pagination: Pagination
|
||||
|
||||
|
||||
# Search
|
||||
class SearchRequest(BaseModel):
|
||||
search_query: str | None = None
|
||||
filter: list[FilterType] | None = None
|
||||
by_creator: list[str] | None = None
|
||||
search_id: str | None = None
|
||||
page: int | None = None
|
||||
page_size: int | None = None
|
||||
|
||||
|
||||
class SearchBlocksResponse(BaseModel):
|
||||
blocks: BlockResponse
|
||||
total_block_count: int
|
||||
|
||||
@@ -128,30 +128,34 @@ async def get_providers(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
# Not using post method because on frontend, orval doesn't support Infinite Query with POST method.
|
||||
@router.get(
|
||||
"/search",
|
||||
summary="Builder search",
|
||||
tags=["store", "private"],
|
||||
response_model=builder_model.SearchResponse,
|
||||
)
|
||||
async def search(
|
||||
options: builder_model.SearchRequest,
|
||||
user_id: Annotated[str, fastapi.Security(get_user_id)],
|
||||
search_query: Annotated[str | None, fastapi.Query()] = None,
|
||||
filter: Annotated[list[str] | None, fastapi.Query()] = None,
|
||||
search_id: Annotated[str | None, fastapi.Query()] = None,
|
||||
by_creator: Annotated[list[str] | None, fastapi.Query()] = None,
|
||||
page: Annotated[int, fastapi.Query()] = 1,
|
||||
page_size: Annotated[int, fastapi.Query()] = 50,
|
||||
) -> builder_model.SearchResponse:
|
||||
"""
|
||||
Search for blocks (including integrations), marketplace agents, and user library agents.
|
||||
"""
|
||||
# If no filters are provided, then we will return all types
|
||||
if not options.filter:
|
||||
options.filter = [
|
||||
if not filter:
|
||||
filter = [
|
||||
"blocks",
|
||||
"integrations",
|
||||
"marketplace_agents",
|
||||
"my_agents",
|
||||
]
|
||||
options.search_query = sanitize_query(options.search_query)
|
||||
options.page = options.page or 1
|
||||
options.page_size = options.page_size or 50
|
||||
search_query = sanitize_query(search_query)
|
||||
|
||||
# Blocks&Integrations
|
||||
blocks = builder_model.SearchBlocksResponse(
|
||||
@@ -162,13 +166,13 @@ async def search(
|
||||
total_block_count=0,
|
||||
total_integration_count=0,
|
||||
)
|
||||
if "blocks" in options.filter or "integrations" in options.filter:
|
||||
if "blocks" in filter or "integrations" in filter:
|
||||
blocks = builder_db.search_blocks(
|
||||
include_blocks="blocks" in options.filter,
|
||||
include_integrations="integrations" in options.filter,
|
||||
query=options.search_query or "",
|
||||
page=options.page,
|
||||
page_size=options.page_size,
|
||||
include_blocks="blocks" in filter,
|
||||
include_integrations="integrations" in filter,
|
||||
query=search_query or "",
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
# Library Agents
|
||||
@@ -176,12 +180,12 @@ async def search(
|
||||
agents=[],
|
||||
pagination=Pagination.empty(),
|
||||
)
|
||||
if "my_agents" in options.filter:
|
||||
if "my_agents" in filter:
|
||||
my_agents = await library_db.list_library_agents(
|
||||
user_id=user_id,
|
||||
search_term=options.search_query,
|
||||
page=options.page,
|
||||
page_size=options.page_size,
|
||||
search_term=search_query,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
# Marketplace Agents
|
||||
@@ -189,12 +193,12 @@ async def search(
|
||||
agents=[],
|
||||
pagination=Pagination.empty(),
|
||||
)
|
||||
if "marketplace_agents" in options.filter:
|
||||
if "marketplace_agents" in filter:
|
||||
marketplace_agents = await store_db.get_store_agents(
|
||||
creators=options.by_creator,
|
||||
search_query=options.search_query,
|
||||
page=options.page,
|
||||
page_size=options.page_size,
|
||||
creators=by_creator,
|
||||
search_query=search_query,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
more_pages = False
|
||||
@@ -214,7 +218,7 @@ async def search(
|
||||
"marketplace_agents": marketplace_agents.pagination.total_items,
|
||||
"my_agents": my_agents.pagination.total_items,
|
||||
},
|
||||
page=options.page,
|
||||
page=page,
|
||||
more_pages=more_pages,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,34 +20,73 @@ logger = logging.getLogger(__name__)
|
||||
# Alert threshold for excessive retries
|
||||
EXCESSIVE_RETRY_THRESHOLD = 50
|
||||
|
||||
# Rate limiting for alerts - track last alert time per function+error combination
|
||||
_alert_rate_limiter = {}
|
||||
_rate_limiter_lock = threading.Lock()
|
||||
ALERT_RATE_LIMIT_SECONDS = 300 # 5 minutes between same alerts
|
||||
|
||||
|
||||
def should_send_alert(func_name: str, exception: Exception, context: str = "") -> bool:
|
||||
"""Check if we should send an alert based on rate limiting."""
|
||||
# Create a unique key for this function+error+context combination
|
||||
error_signature = (
|
||||
f"{context}:{func_name}:{type(exception).__name__}:{str(exception)[:100]}"
|
||||
)
|
||||
current_time = time.time()
|
||||
|
||||
with _rate_limiter_lock:
|
||||
last_alert_time = _alert_rate_limiter.get(error_signature, 0)
|
||||
if current_time - last_alert_time >= ALERT_RATE_LIMIT_SECONDS:
|
||||
_alert_rate_limiter[error_signature] = current_time
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def send_rate_limited_discord_alert(
|
||||
func_name: str, exception: Exception, context: str, alert_msg: str, channel=None
|
||||
) -> bool:
|
||||
"""
|
||||
Send a Discord alert with rate limiting.
|
||||
|
||||
Returns True if alert was sent, False if rate limited.
|
||||
"""
|
||||
if not should_send_alert(func_name, exception, context):
|
||||
return False
|
||||
|
||||
try:
|
||||
from backend.util.clients import get_notification_manager_client
|
||||
from backend.util.metrics import DiscordChannel
|
||||
|
||||
notification_client = get_notification_manager_client()
|
||||
notification_client.discord_system_alert(
|
||||
alert_msg, channel or DiscordChannel.PLATFORM
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as alert_error:
|
||||
logger.error(f"Failed to send Discord alert: {alert_error}")
|
||||
return False
|
||||
|
||||
|
||||
def _send_critical_retry_alert(
|
||||
func_name: str, attempt_number: int, exception: Exception, context: str = ""
|
||||
):
|
||||
"""Send alert when a function is approaching the retry failure threshold."""
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from backend.util.clients import get_notification_manager_client
|
||||
|
||||
notification_client = get_notification_manager_client()
|
||||
|
||||
prefix = f"{context}: " if context else ""
|
||||
alert_msg = (
|
||||
f"🚨 CRITICAL: Operation Approaching Failure Threshold: {prefix}'{func_name}'\n\n"
|
||||
f"Current attempt: {attempt_number}/{EXCESSIVE_RETRY_THRESHOLD}\n"
|
||||
f"Error: {type(exception).__name__}: {exception}\n\n"
|
||||
f"This operation is about to fail permanently. Investigate immediately."
|
||||
)
|
||||
|
||||
notification_client.discord_system_alert(alert_msg)
|
||||
prefix = f"{context}: " if context else ""
|
||||
if send_rate_limited_discord_alert(
|
||||
func_name,
|
||||
exception,
|
||||
context,
|
||||
f"🚨 CRITICAL: Operation Approaching Failure Threshold: {prefix}'{func_name}'\n\n"
|
||||
f"Current attempt: {attempt_number}/{EXCESSIVE_RETRY_THRESHOLD}\n"
|
||||
f"Error: {type(exception).__name__}: {exception}\n\n"
|
||||
f"This operation is about to fail permanently. Investigate immediately.",
|
||||
):
|
||||
logger.critical(
|
||||
f"CRITICAL ALERT SENT: Operation {func_name} at attempt {attempt_number}"
|
||||
)
|
||||
|
||||
except Exception as alert_error:
|
||||
logger.error(f"Failed to send critical retry alert: {alert_error}")
|
||||
# Don't let alerting failures break the main flow
|
||||
|
||||
|
||||
def _create_retry_callback(context: str = ""):
|
||||
"""Create a retry callback with optional context."""
|
||||
@@ -66,7 +105,7 @@ def _create_retry_callback(context: str = ""):
|
||||
f"{type(exception).__name__}: {exception}"
|
||||
)
|
||||
else:
|
||||
# Retry attempt - send critical alert only once at threshold
|
||||
# Retry attempt - send critical alert only once at threshold (rate limited)
|
||||
if attempt_number == EXCESSIVE_RETRY_THRESHOLD:
|
||||
_send_critical_retry_alert(
|
||||
func_name, attempt_number, exception, context
|
||||
@@ -131,7 +170,7 @@ def _log_prefix(resource_name: str, conn_id: str):
|
||||
def conn_retry(
|
||||
resource_name: str,
|
||||
action_name: str,
|
||||
max_retry: int = 5,
|
||||
max_retry: int = 100,
|
||||
max_wait: float = 30,
|
||||
):
|
||||
conn_id = str(uuid4())
|
||||
@@ -139,10 +178,29 @@ def conn_retry(
|
||||
def on_retry(retry_state):
|
||||
prefix = _log_prefix(resource_name, conn_id)
|
||||
exception = retry_state.outcome.exception()
|
||||
attempt_number = retry_state.attempt_number
|
||||
func_name = getattr(retry_state.fn, "__name__", "unknown")
|
||||
|
||||
if retry_state.outcome.failed and retry_state.next_action is None:
|
||||
logger.error(f"{prefix} {action_name} failed after retries: {exception}")
|
||||
else:
|
||||
if attempt_number == EXCESSIVE_RETRY_THRESHOLD:
|
||||
if send_rate_limited_discord_alert(
|
||||
func_name,
|
||||
exception,
|
||||
f"{resource_name}_infrastructure",
|
||||
f"🚨 **Critical Infrastructure Connection Issue**\n"
|
||||
f"Resource: {resource_name}\n"
|
||||
f"Action: {action_name}\n"
|
||||
f"Function: {func_name}\n"
|
||||
f"Current attempt: {attempt_number}/{max_retry + 1}\n"
|
||||
f"Error: {type(exception).__name__}: {str(exception)[:200]}{'...' if len(str(exception)) > 200 else ''}\n\n"
|
||||
f"Infrastructure component is approaching failure threshold. Investigate immediately.",
|
||||
):
|
||||
logger.critical(
|
||||
f"INFRASTRUCTURE ALERT SENT: {resource_name} at {attempt_number} attempts"
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"{prefix} {action_name} failed: {exception}. Retrying now..."
|
||||
)
|
||||
@@ -218,8 +276,8 @@ def continuous_retry(*, retry_delay: float = 1.0):
|
||||
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
counter = 0
|
||||
while True:
|
||||
counter = 0
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.util.retry import conn_retry
|
||||
from backend.util.retry import (
|
||||
ALERT_RATE_LIMIT_SECONDS,
|
||||
_alert_rate_limiter,
|
||||
_rate_limiter_lock,
|
||||
_send_critical_retry_alert,
|
||||
conn_retry,
|
||||
create_retry_decorator,
|
||||
should_send_alert,
|
||||
)
|
||||
|
||||
|
||||
def test_conn_retry_sync_function():
|
||||
@@ -47,3 +58,194 @@ async def test_conn_retry_async_function():
|
||||
with pytest.raises(ValueError) as e:
|
||||
await test_function()
|
||||
assert str(e.value) == "Test error"
|
||||
|
||||
|
||||
class TestRetryRateLimiting:
|
||||
"""Test the rate limiting functionality for critical retry alerts."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset rate limiter state before each test."""
|
||||
with _rate_limiter_lock:
|
||||
_alert_rate_limiter.clear()
|
||||
|
||||
def test_should_send_alert_allows_first_occurrence(self):
|
||||
"""Test that the first occurrence of an error allows alert."""
|
||||
exc = ValueError("test error")
|
||||
assert should_send_alert("test_func", exc, "test_context") is True
|
||||
|
||||
def test_should_send_alert_rate_limits_duplicate(self):
|
||||
"""Test that duplicate errors are rate limited."""
|
||||
exc = ValueError("test error")
|
||||
|
||||
# First call should be allowed
|
||||
assert should_send_alert("test_func", exc, "test_context") is True
|
||||
|
||||
# Second call should be rate limited
|
||||
assert should_send_alert("test_func", exc, "test_context") is False
|
||||
|
||||
def test_should_send_alert_allows_different_errors(self):
|
||||
"""Test that different errors are allowed even if same function."""
|
||||
exc1 = ValueError("error 1")
|
||||
exc2 = ValueError("error 2")
|
||||
|
||||
# First error should be allowed
|
||||
assert should_send_alert("test_func", exc1, "test_context") is True
|
||||
|
||||
# Different error should also be allowed
|
||||
assert should_send_alert("test_func", exc2, "test_context") is True
|
||||
|
||||
def test_should_send_alert_allows_different_contexts(self):
|
||||
"""Test that same error in different contexts is allowed."""
|
||||
exc = ValueError("test error")
|
||||
|
||||
# First context should be allowed
|
||||
assert should_send_alert("test_func", exc, "context1") is True
|
||||
|
||||
# Different context should also be allowed
|
||||
assert should_send_alert("test_func", exc, "context2") is True
|
||||
|
||||
def test_should_send_alert_allows_different_functions(self):
|
||||
"""Test that same error in different functions is allowed."""
|
||||
exc = ValueError("test error")
|
||||
|
||||
# First function should be allowed
|
||||
assert should_send_alert("func1", exc, "test_context") is True
|
||||
|
||||
# Different function should also be allowed
|
||||
assert should_send_alert("func2", exc, "test_context") is True
|
||||
|
||||
def test_should_send_alert_respects_time_window(self):
|
||||
"""Test that alerts are allowed again after the rate limit window."""
|
||||
exc = ValueError("test error")
|
||||
|
||||
# First call should be allowed
|
||||
assert should_send_alert("test_func", exc, "test_context") is True
|
||||
|
||||
# Immediately after should be rate limited
|
||||
assert should_send_alert("test_func", exc, "test_context") is False
|
||||
|
||||
# Mock time to simulate passage of rate limit window
|
||||
current_time = time.time()
|
||||
with patch("backend.util.retry.time.time") as mock_time:
|
||||
# Simulate time passing beyond rate limit window
|
||||
mock_time.return_value = current_time + ALERT_RATE_LIMIT_SECONDS + 1
|
||||
assert should_send_alert("test_func", exc, "test_context") is True
|
||||
|
||||
def test_should_send_alert_thread_safety(self):
|
||||
"""Test that rate limiting is thread-safe."""
|
||||
exc = ValueError("test error")
|
||||
results = []
|
||||
|
||||
def check_alert():
|
||||
result = should_send_alert("test_func", exc, "test_context")
|
||||
results.append(result)
|
||||
|
||||
# Create multiple threads trying to send the same alert
|
||||
threads = [threading.Thread(target=check_alert) for _ in range(10)]
|
||||
|
||||
# Start all threads
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
# Wait for all threads to complete
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
# Only one thread should have been allowed to send the alert
|
||||
assert sum(results) == 1
|
||||
assert len([r for r in results if r is True]) == 1
|
||||
assert len([r for r in results if r is False]) == 9
|
||||
|
||||
@patch("backend.util.clients.get_notification_manager_client")
|
||||
def test_send_critical_retry_alert_rate_limiting(self, mock_get_client):
|
||||
"""Test that _send_critical_retry_alert respects rate limiting."""
|
||||
mock_client = Mock()
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
exc = ValueError("spend_credits API error")
|
||||
|
||||
# First alert should be sent
|
||||
_send_critical_retry_alert("spend_credits", 50, exc, "Service communication")
|
||||
assert mock_client.discord_system_alert.call_count == 1
|
||||
|
||||
# Second identical alert should be rate limited (not sent)
|
||||
_send_critical_retry_alert("spend_credits", 50, exc, "Service communication")
|
||||
assert mock_client.discord_system_alert.call_count == 1 # Still 1, not 2
|
||||
|
||||
# Different error should be allowed
|
||||
exc2 = ValueError("different API error")
|
||||
_send_critical_retry_alert("spend_credits", 50, exc2, "Service communication")
|
||||
assert mock_client.discord_system_alert.call_count == 2
|
||||
|
||||
@patch("backend.util.clients.get_notification_manager_client")
|
||||
def test_send_critical_retry_alert_handles_notification_failure(
|
||||
self, mock_get_client
|
||||
):
|
||||
"""Test that notification failures don't break the rate limiter."""
|
||||
mock_client = Mock()
|
||||
mock_client.discord_system_alert.side_effect = Exception("Notification failed")
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
exc = ValueError("test error")
|
||||
|
||||
# Should not raise exception even if notification fails
|
||||
_send_critical_retry_alert("test_func", 50, exc, "test_context")
|
||||
|
||||
# Rate limiter should still work for subsequent calls
|
||||
assert should_send_alert("test_func", exc, "test_context") is False
|
||||
|
||||
def test_error_signature_generation(self):
|
||||
"""Test that error signatures are generated correctly for rate limiting."""
|
||||
# Test with long exception message (should be truncated to 100 chars)
|
||||
long_message = "x" * 200
|
||||
exc = ValueError(long_message)
|
||||
|
||||
# Should not raise exception and should work normally
|
||||
assert should_send_alert("test_func", exc, "test_context") is True
|
||||
assert should_send_alert("test_func", exc, "test_context") is False
|
||||
|
||||
def test_real_world_scenario_spend_credits_spam(self):
|
||||
"""Test the real-world scenario that was causing spam."""
|
||||
# Simulate the exact error that was causing issues
|
||||
exc = Exception(
|
||||
"HTTP 500: Server error '500 Internal Server Error' for url 'http://autogpt-database-manager.prod-agpt.svc.cluster.local:8005/spend_credits'"
|
||||
)
|
||||
|
||||
# First 50 attempts reach threshold - should send alert
|
||||
with patch(
|
||||
"backend.util.clients.get_notification_manager_client"
|
||||
) as mock_get_client:
|
||||
mock_client = Mock()
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
_send_critical_retry_alert(
|
||||
"_call_method_sync", 50, exc, "Service communication"
|
||||
)
|
||||
assert mock_client.discord_system_alert.call_count == 1
|
||||
|
||||
# Next 950 failures should not send alerts (rate limited)
|
||||
for _ in range(950):
|
||||
_send_critical_retry_alert(
|
||||
"_call_method_sync", 50, exc, "Service communication"
|
||||
)
|
||||
|
||||
# Still only 1 alert sent total
|
||||
assert mock_client.discord_system_alert.call_count == 1
|
||||
|
||||
@patch("backend.util.clients.get_notification_manager_client")
|
||||
def test_retry_decorator_with_excessive_failures(self, mock_get_client):
|
||||
"""Test retry decorator behavior when it hits the alert threshold."""
|
||||
mock_client = Mock()
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
@create_retry_decorator(
|
||||
max_attempts=60, max_wait=0.1
|
||||
) # More than EXCESSIVE_RETRY_THRESHOLD, but fast
|
||||
def always_failing_function():
|
||||
raise ValueError("persistent failure")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
always_failing_function()
|
||||
|
||||
# Should have sent exactly one alert at the threshold
|
||||
assert mock_client.discord_system_alert.call_count == 1
|
||||
|
||||
@@ -28,6 +28,7 @@ from fastapi import FastAPI, Request, responses
|
||||
from pydantic import BaseModel, TypeAdapter, create_model
|
||||
|
||||
import backend.util.exceptions as exceptions
|
||||
from backend.monitoring.instrumentation import instrument_fastapi
|
||||
from backend.util.json import to_dict
|
||||
from backend.util.metrics import sentry_init
|
||||
from backend.util.process import AppProcess, get_service_name
|
||||
@@ -283,6 +284,24 @@ class AppService(BaseAppService, ABC):
|
||||
super().run()
|
||||
self.fastapi_app = FastAPI()
|
||||
|
||||
# Add Prometheus instrumentation to all services
|
||||
try:
|
||||
instrument_fastapi(
|
||||
self.fastapi_app,
|
||||
service_name=self.service_name,
|
||||
expose_endpoint=True,
|
||||
endpoint="/metrics",
|
||||
include_in_schema=False,
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
f"Prometheus instrumentation not available for {self.service_name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to instrument {self.service_name} with Prometheus: {e}"
|
||||
)
|
||||
|
||||
# Register the exposed API routes.
|
||||
for attr_name, attr in vars(type(self)).items():
|
||||
if getattr(attr, EXPOSED_FLAG, False):
|
||||
|
||||
@@ -67,6 +67,12 @@ export default defineConfig({
|
||||
useQuery: true,
|
||||
},
|
||||
},
|
||||
"getV2Builder search": {
|
||||
query: {
|
||||
useInfinite: true,
|
||||
useInfiniteQueryParam: "page",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,15 +9,16 @@ import {
|
||||
import { X } from "lucide-react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { PopoverClose } from "@radix-ui/react-popover";
|
||||
import { TaskGroups } from "@/app/(no-navbar)/onboarding/components/WalletTaskGroups";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { TaskGroups } from "@/app/(no-navbar)/onboarding/components/Wallet/components/WalletTaskGroups";
|
||||
import { ScrollArea } from "../../../../../components/__legacy__/ui/scroll-area";
|
||||
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as party from "party-js";
|
||||
import WalletRefill from "./WalletRefill";
|
||||
import WalletRefill from "./components/WalletRefill";
|
||||
import { OnboardingStep } from "@/lib/autogpt-server-api";
|
||||
import { storage, Key as StorageKey } from "@/services/storage/local-storage";
|
||||
import { WalletIcon } from "@phosphor-icons/react";
|
||||
|
||||
export interface Task {
|
||||
id: OnboardingStep;
|
||||
@@ -304,6 +305,9 @@ export default function Wallet() {
|
||||
}, 300);
|
||||
}, [credits, prevCredits]);
|
||||
|
||||
// Do not render until we have both credits and onboarding data
|
||||
if (credits === null || !state) return null;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={walletOpen}
|
||||
@@ -323,21 +327,24 @@ export default function Wallet() {
|
||||
<button
|
||||
ref={walletRef}
|
||||
className={cn(
|
||||
"relative flex items-center gap-1 rounded-md bg-zinc-50 px-3 py-2 text-sm",
|
||||
"relative flex flex-nowrap items-center gap-2 rounded-md bg-zinc-50 px-3 py-2 text-sm",
|
||||
)}
|
||||
onClick={onWalletOpen}
|
||||
>
|
||||
Earn credits{" "}
|
||||
<span className="text-sm font-semibold">
|
||||
{formatCredits(credits)}
|
||||
</span>
|
||||
{completedCount && completedCount < totalCount && (
|
||||
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-violet-600"></span>
|
||||
)}
|
||||
<div className="absolute bottom-[-2.5rem] left-1/2 z-50 hidden -translate-x-1/2 transform whitespace-nowrap rounded-small bg-white px-4 py-2 shadow-md group-hover:block">
|
||||
<Text variant="body-medium">
|
||||
{completedCount} of {totalCount} rewards claimed
|
||||
</Text>
|
||||
<WalletIcon size={20} className="inline-block md:hidden" />
|
||||
<div>
|
||||
<span className="mr-1 hidden md:inline-block">Earn credits </span>
|
||||
<span className="text-sm font-semibold">
|
||||
{formatCredits(credits)}
|
||||
</span>
|
||||
{completedCount && completedCount < totalCount && (
|
||||
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-violet-600"></span>
|
||||
)}
|
||||
<div className="absolute bottom-[-2.5rem] left-1/2 z-50 hidden -translate-x-1/2 transform whitespace-nowrap rounded-small bg-white px-4 py-2 shadow-md group-hover:block">
|
||||
<Text variant="body-medium">
|
||||
{completedCount} of {totalCount} rewards claimed
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
@@ -16,9 +16,12 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/__legacy__/ui/form";
|
||||
import { Input } from "./ui/input";
|
||||
import { Input } from "../../../../../../components/__legacy__/ui/input";
|
||||
import Link from "next/link";
|
||||
import { useToast, useToastOnFail } from "../molecules/Toast/use-toast";
|
||||
import {
|
||||
useToast,
|
||||
useToastOnFail,
|
||||
} from "../../../../../../components/molecules/Toast/use-toast";
|
||||
import useCredits from "@/hooks/useCredits";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
@@ -3,7 +3,10 @@ import { ChevronDown, Check, BadgeQuestionMark } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as party from "party-js";
|
||||
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
|
||||
import { Task, TaskGroup } from "@/components/__legacy__/Wallet";
|
||||
import {
|
||||
Task,
|
||||
TaskGroup,
|
||||
} from "@/app/(no-navbar)/onboarding/components/Wallet/Wallet";
|
||||
|
||||
interface Props {
|
||||
groups: TaskGroup[];
|
||||
@@ -1,14 +1,16 @@
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { redirect } from "next/navigation";
|
||||
import { finishOnboarding } from "./6-congrats/actions";
|
||||
import { shouldShowOnboarding } from "@/app/api/helpers";
|
||||
|
||||
// Force dynamic rendering to avoid static generation issues with cookies
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OnboardingPage() {
|
||||
const api = new BackendAPI();
|
||||
const isOnboardingEnabled = await shouldShowOnboarding();
|
||||
|
||||
if (!api.isOnboardingEnabled()) {
|
||||
if (!isOnboardingEnabled) {
|
||||
redirect("/marketplace");
|
||||
}
|
||||
|
||||
@@ -23,7 +25,7 @@ export default async function OnboardingPage() {
|
||||
else if (onboarding.completedSteps.includes("AGENT_NEW_RUN"))
|
||||
redirect("/onboarding/5-run");
|
||||
else if (onboarding.completedSteps.includes("AGENT_CHOICE"))
|
||||
redirect("/onboarding/5-agent");
|
||||
redirect("/onboarding/5-run");
|
||||
else if (onboarding.completedSteps.includes("INTEGRATIONS"))
|
||||
redirect("/onboarding/4-agent");
|
||||
else if (onboarding.completedSteps.includes("USAGE_REASON"))
|
||||
|
||||
@@ -2,14 +2,7 @@ import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { NextResponse } from "next/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
async function shouldShowOnboarding() {
|
||||
const api = new BackendAPI();
|
||||
return (
|
||||
(await api.isOnboardingEnabled()) &&
|
||||
!(await api.getUserOnboarding()).completedSteps.includes("CONGRATS")
|
||||
);
|
||||
}
|
||||
import { shouldShowOnboarding } from "@/app/api/helpers";
|
||||
|
||||
// Handle the callback to complete the user session login
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { preprocessInputSchema } from "../processors/input-schema-pre-processor"
|
||||
import { OutputHandler } from "./OutputHandler";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BlockUIType } from "../../types";
|
||||
import { StickyNoteBlock } from "./StickyNoteBlock";
|
||||
|
||||
export type CustomNodeData = {
|
||||
hardcodedValues: {
|
||||
@@ -18,6 +20,7 @@ export type CustomNodeData = {
|
||||
description: string;
|
||||
inputSchema: RJSFSchema;
|
||||
outputSchema: RJSFSchema;
|
||||
uiType: BlockUIType;
|
||||
};
|
||||
|
||||
export type CustomNode = XYNode<CustomNodeData, "custom">;
|
||||
@@ -29,6 +32,10 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
);
|
||||
const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced);
|
||||
|
||||
if (data.uiType === BlockUIType.NOTE) {
|
||||
return <StickyNoteBlock selected={selected} data={data} id={id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -51,6 +58,7 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
<FormCreator
|
||||
jsonSchema={preprocessInputSchema(data.inputSchema)}
|
||||
nodeId={id}
|
||||
uiType={data.uiType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,9 +7,18 @@ import { fields } from "./fields";
|
||||
import { templates } from "./templates";
|
||||
import { uiSchema } from "./uiSchema";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { BlockUIType } from "../../types";
|
||||
|
||||
export const FormCreator = React.memo(
|
||||
({ jsonSchema, nodeId }: { jsonSchema: RJSFSchema; nodeId: string }) => {
|
||||
({
|
||||
jsonSchema,
|
||||
nodeId,
|
||||
uiType,
|
||||
}: {
|
||||
jsonSchema: RJSFSchema;
|
||||
nodeId: string;
|
||||
uiType: BlockUIType;
|
||||
}) => {
|
||||
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
||||
const handleChange = ({ formData }: any) => {
|
||||
updateNodeData(nodeId, { hardcodedValues: formData });
|
||||
@@ -22,7 +31,7 @@ export const FormCreator = React.memo(
|
||||
fields={fields}
|
||||
templates={templates}
|
||||
widgets={widgets}
|
||||
formContext={{ nodeId: nodeId }}
|
||||
formContext={{ nodeId: nodeId, uiType: uiType }}
|
||||
onChange={handleChange}
|
||||
uiSchema={uiSchema}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useMemo } from "react";
|
||||
import { FormCreator } from "./FormCreator";
|
||||
import { preprocessInputSchema } from "../processors/input-schema-pre-processor";
|
||||
import { CustomNodeData } from "./CustomNode";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type StickyNoteBlockType = {
|
||||
selected: boolean;
|
||||
data: CustomNodeData;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const StickyNoteBlock = ({ data, id }: StickyNoteBlockType) => {
|
||||
const { angle, color } = useMemo(() => {
|
||||
const hash = id.split("").reduce((acc, char) => {
|
||||
return char.charCodeAt(0) + ((acc << 5) - acc);
|
||||
}, 0);
|
||||
|
||||
const colors = [
|
||||
"bg-orange-200",
|
||||
"bg-red-200",
|
||||
"bg-yellow-200",
|
||||
"bg-green-200",
|
||||
"bg-blue-200",
|
||||
"bg-purple-200",
|
||||
"bg-pink-200",
|
||||
];
|
||||
|
||||
return {
|
||||
angle: (hash % 7) - 3,
|
||||
color: colors[Math.abs(hash) % colors.length],
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-76 w-76 p-4 text-black shadow-[rgba(0,0,0,0.3)_-2px_5px_5px_0px]",
|
||||
color,
|
||||
)}
|
||||
style={{ transform: `rotate(${angle}deg)` }}
|
||||
>
|
||||
<Text variant="h3" className="tracking-tight text-slate-800">
|
||||
Notes #{id}
|
||||
</Text>
|
||||
<FormCreator
|
||||
jsonSchema={preprocessInputSchema(data.inputSchema)}
|
||||
nodeId={id}
|
||||
uiType={data.uiType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { FieldProps } from "@rjsf/utils";
|
||||
import { useCredentialField } from "./useCredentialField";
|
||||
import { KeyIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { SelectCredential } from "./SelectCredential";
|
||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
|
||||
import { APIKeyCredentialsModal } from "./models/APIKeyCredentialModal/APIKeyCredentialModal";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { OAuthCredentialModal } from "./models/OAuthCredentialModal/OAuthCredentialModal";
|
||||
import { PasswordCredentialsModal } from "./models/PasswordCredentialModal/PasswordCredentialModal";
|
||||
|
||||
export const CredentialsField = (props: FieldProps) => {
|
||||
const { formData = {}, onChange, required: _required, schema } = props;
|
||||
@@ -16,8 +15,7 @@ export const CredentialsField = (props: FieldProps) => {
|
||||
isCredentialListLoading,
|
||||
supportsApiKey,
|
||||
supportsOAuth2,
|
||||
isAPIKeyModalOpen,
|
||||
setIsAPIKeyModalOpen,
|
||||
supportsUserPassword,
|
||||
credentialsExists,
|
||||
} = useCredentialField({
|
||||
credentialSchema: schema as BlockIOCredentialsSubSchema,
|
||||
@@ -26,6 +24,7 @@ export const CredentialsField = (props: FieldProps) => {
|
||||
const setField = (key: string, value: any) =>
|
||||
onChange({ ...formData, [key]: value });
|
||||
|
||||
// This is to set the latest credential as the default one [currently, latest means last one in the list of credentials]
|
||||
useEffect(() => {
|
||||
if (!isCredentialListLoading && credentials.length > 0 && !formData.id) {
|
||||
const latestCredential = credentials[credentials.length - 1];
|
||||
@@ -33,10 +32,6 @@ export const CredentialsField = (props: FieldProps) => {
|
||||
}
|
||||
}, [isCredentialListLoading, credentials, formData.id]);
|
||||
|
||||
const handleCredentialCreated = (credentialId: string) => {
|
||||
setField("id", credentialId);
|
||||
};
|
||||
|
||||
if (isCredentialListLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -61,31 +56,18 @@ export const CredentialsField = (props: FieldProps) => {
|
||||
|
||||
<div>
|
||||
{supportsApiKey && (
|
||||
<>
|
||||
<APIKeyCredentialsModal
|
||||
schema={schema as BlockIOCredentialsSubSchema}
|
||||
open={isAPIKeyModalOpen}
|
||||
onClose={() => setIsAPIKeyModalOpen(false)}
|
||||
onSuccess={handleCredentialCreated}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-auto min-w-0"
|
||||
size="small"
|
||||
onClick={() => setIsAPIKeyModalOpen(true)}
|
||||
>
|
||||
<KeyIcon />
|
||||
<Text variant="body-medium" className="!text-white opacity-100">
|
||||
Add API key
|
||||
</Text>
|
||||
</Button>
|
||||
</>
|
||||
<APIKeyCredentialsModal
|
||||
schema={schema as BlockIOCredentialsSubSchema}
|
||||
/>
|
||||
)}
|
||||
{supportsOAuth2 && (
|
||||
<Button type="button" className="w-fit" size="small">
|
||||
<PlusIcon />
|
||||
Add OAuth2
|
||||
</Button>
|
||||
<OAuthCredentialModal provider={schema.credentials_provider[0]} />
|
||||
)}
|
||||
{supportsUserPassword && (
|
||||
<PasswordCredentialsModal
|
||||
schema={schema as BlockIOCredentialsSubSchema}
|
||||
provider={schema.credentials_provider[0]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import React from "react";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
import { CredentialsMetaResponse } from "@/app/api/__generated__/models/credentialsMetaResponse";
|
||||
import { ArrowSquareOutIcon, KeyIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
ArrowSquareOutIcon,
|
||||
KeyholeIcon,
|
||||
KeyIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import Link from "next/link";
|
||||
import { providerIcons } from "./helpers";
|
||||
|
||||
type SelectCredentialProps = {
|
||||
credentials: CredentialsMetaResponse[];
|
||||
@@ -38,10 +43,22 @@ export const SelectCredential: React.FC<SelectCredentialProps> = ({
|
||||
? `${cred.provider} (${details.join(" - ")})`
|
||||
: cred.provider;
|
||||
|
||||
const Icon = providerIcons[cred.provider];
|
||||
const icon =
|
||||
cred.type === "oauth2" ? (
|
||||
Icon ? (
|
||||
<Icon />
|
||||
) : (
|
||||
<KeyholeIcon />
|
||||
)
|
||||
) : (
|
||||
<KeyIcon className="h-4 w-4" />
|
||||
);
|
||||
|
||||
return {
|
||||
value: cred.id,
|
||||
label,
|
||||
icon: <KeyIcon className="h-4 w-4" />,
|
||||
icon,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { CredentialsMetaResponse } from "@/app/api/__generated__/models/credentialsMetaResponse";
|
||||
// Need to replace these icons with phosphor icons
|
||||
import {
|
||||
FaDiscord,
|
||||
FaMedium,
|
||||
FaGithub,
|
||||
FaGoogle,
|
||||
FaHubspot,
|
||||
FaTwitter,
|
||||
} from "react-icons/fa";
|
||||
import { GoogleLogoIcon, KeyIcon, NotionLogoIcon } from "@phosphor-icons/react";
|
||||
GoogleLogoIcon,
|
||||
KeyholeIcon,
|
||||
NotionLogoIcon,
|
||||
DiscordLogoIcon,
|
||||
MediumLogoIcon,
|
||||
GithubLogoIcon,
|
||||
TwitterLogoIcon,
|
||||
Icon,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
export const filterCredentialsByProvider = (
|
||||
credentials: CredentialsMetaResponse[] | undefined,
|
||||
@@ -56,46 +56,44 @@ export function isCredentialFieldSchema(schema: any): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export const providerIcons: Partial<
|
||||
Record<string, React.FC<{ className?: string }>>
|
||||
> = {
|
||||
aiml_api: KeyIcon,
|
||||
anthropic: KeyIcon,
|
||||
apollo: KeyIcon,
|
||||
e2b: KeyIcon,
|
||||
github: FaGithub,
|
||||
export const providerIcons: Partial<Record<string, Icon>> = {
|
||||
aiml_api: KeyholeIcon,
|
||||
anthropic: KeyholeIcon,
|
||||
apollo: KeyholeIcon,
|
||||
e2b: KeyholeIcon,
|
||||
github: GithubLogoIcon,
|
||||
google: GoogleLogoIcon,
|
||||
groq: KeyIcon,
|
||||
http: KeyIcon,
|
||||
groq: KeyholeIcon,
|
||||
http: KeyholeIcon,
|
||||
notion: NotionLogoIcon,
|
||||
nvidia: KeyIcon,
|
||||
discord: FaDiscord,
|
||||
d_id: KeyIcon,
|
||||
google_maps: FaGoogle,
|
||||
jina: KeyIcon,
|
||||
ideogram: KeyIcon,
|
||||
linear: KeyIcon,
|
||||
medium: FaMedium,
|
||||
mem0: KeyIcon,
|
||||
ollama: KeyIcon,
|
||||
openai: KeyIcon,
|
||||
openweathermap: KeyIcon,
|
||||
open_router: KeyIcon,
|
||||
llama_api: KeyIcon,
|
||||
pinecone: KeyIcon,
|
||||
enrichlayer: KeyIcon,
|
||||
slant3d: KeyIcon,
|
||||
screenshotone: KeyIcon,
|
||||
smtp: KeyIcon,
|
||||
replicate: KeyIcon,
|
||||
reddit: KeyIcon,
|
||||
fal: KeyIcon,
|
||||
revid: KeyIcon,
|
||||
twitter: FaTwitter,
|
||||
unreal_speech: KeyIcon,
|
||||
exa: KeyIcon,
|
||||
hubspot: FaHubspot,
|
||||
smartlead: KeyIcon,
|
||||
todoist: KeyIcon,
|
||||
zerobounce: KeyIcon,
|
||||
nvidia: KeyholeIcon,
|
||||
discord: DiscordLogoIcon,
|
||||
d_id: KeyholeIcon,
|
||||
google_maps: GoogleLogoIcon,
|
||||
jina: KeyholeIcon,
|
||||
ideogram: KeyholeIcon,
|
||||
linear: KeyholeIcon,
|
||||
medium: MediumLogoIcon,
|
||||
mem0: KeyholeIcon,
|
||||
ollama: KeyholeIcon,
|
||||
openai: KeyholeIcon,
|
||||
openweathermap: KeyholeIcon,
|
||||
open_router: KeyholeIcon,
|
||||
llama_api: KeyholeIcon,
|
||||
pinecone: KeyholeIcon,
|
||||
enrichlayer: KeyholeIcon,
|
||||
slant3d: KeyholeIcon,
|
||||
screenshotone: KeyholeIcon,
|
||||
smtp: KeyholeIcon,
|
||||
replicate: KeyholeIcon,
|
||||
reddit: KeyholeIcon,
|
||||
fal: KeyholeIcon,
|
||||
revid: KeyholeIcon,
|
||||
twitter: TwitterLogoIcon,
|
||||
unreal_speech: KeyholeIcon,
|
||||
exa: KeyholeIcon,
|
||||
hubspot: KeyholeIcon,
|
||||
smartlead: KeyholeIcon,
|
||||
todoist: KeyholeIcon,
|
||||
zerobounce: KeyholeIcon,
|
||||
};
|
||||
|
||||
@@ -9,111 +9,125 @@ import {
|
||||
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types"; // we need to find a way to replace it with autogenerated types
|
||||
import { useAPIKeyCredentialsModal } from "./useAPIKeyCredentialsModal";
|
||||
import { toDisplayName } from "../../helpers";
|
||||
import { KeyIcon } from "@phosphor-icons/react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
type Props = {
|
||||
schema: BlockIOCredentialsSubSchema;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (credentialId: string) => void;
|
||||
};
|
||||
|
||||
export function APIKeyCredentialsModal({
|
||||
schema,
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
const { form, isLoading, schemaDescription, onSubmit, provider } =
|
||||
useAPIKeyCredentialsModal({ schema, onClose, onSuccess });
|
||||
export function APIKeyCredentialsModal({ schema }: Props) {
|
||||
const {
|
||||
form,
|
||||
isLoading,
|
||||
schemaDescription,
|
||||
onSubmit,
|
||||
provider,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
} = useAPIKeyCredentialsModal({ schema });
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={`Add new API key for ${toDisplayName(provider) ?? ""}`}
|
||||
controlled={{
|
||||
isOpen: open,
|
||||
set: (isOpen) => {
|
||||
if (!isOpen) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{
|
||||
maxWidth: "25rem",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
{schemaDescription && (
|
||||
<p className="mb-4 text-sm text-zinc-600">{schemaDescription}</p>
|
||||
)}
|
||||
<>
|
||||
<Dialog
|
||||
title={`Add new API key for ${toDisplayName(provider) ?? ""}`}
|
||||
controlled={{
|
||||
isOpen: isOpen,
|
||||
set: (isOpen) => {
|
||||
if (!isOpen) setIsOpen(false);
|
||||
},
|
||||
}}
|
||||
onClose={() => setIsOpen(false)}
|
||||
styling={{
|
||||
maxWidth: "25rem",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
{schemaDescription && (
|
||||
<p className="mb-4 text-sm text-zinc-600">{schemaDescription}</p>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<Input
|
||||
id="apiKey"
|
||||
label="API Key"
|
||||
type="password"
|
||||
placeholder="Enter API key..."
|
||||
size="small"
|
||||
hint={
|
||||
schema.credentials_scopes ? (
|
||||
<FormDescription>
|
||||
Required scope(s) for this block:{" "}
|
||||
{schema.credentials_scopes?.map((s, i, a) => (
|
||||
<span key={i}>
|
||||
<code className="text-xs font-bold">{s}</code>
|
||||
{i < a.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</FormDescription>
|
||||
) : null
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="apiKey"
|
||||
label="API Key"
|
||||
type="password"
|
||||
placeholder="Enter API key..."
|
||||
id="title"
|
||||
label="Name"
|
||||
type="text"
|
||||
placeholder="Enter a name for this API key..."
|
||||
size="small"
|
||||
hint={
|
||||
schema.credentials_scopes ? (
|
||||
<FormDescription>
|
||||
Required scope(s) for this block:{" "}
|
||||
{schema.credentials_scopes?.map((s, i, a) => (
|
||||
<span key={i}>
|
||||
<code className="text-xs font-bold">{s}</code>
|
||||
{i < a.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</FormDescription>
|
||||
) : null
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="title"
|
||||
label="Name"
|
||||
type="text"
|
||||
placeholder="Enter a name for this API key..."
|
||||
size="small"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiresAt"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="expiresAt"
|
||||
label="Expiration Date"
|
||||
type="datetime-local"
|
||||
placeholder="Select expiration date..."
|
||||
size="small"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" size="small" className="min-w-68">
|
||||
Save & use this API key
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiresAt"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="expiresAt"
|
||||
label="Expiration Date"
|
||||
type="datetime-local"
|
||||
placeholder="Select expiration date..."
|
||||
size="small"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" size="small" className="min-w-68">
|
||||
Save & use this API key
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-auto min-w-0"
|
||||
size="small"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<KeyIcon />
|
||||
<Text variant="body-medium" className="!text-white opacity-100">
|
||||
Add API key
|
||||
</Text>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { APIKeyCredentials } from "@/app/api/__generated__/models/aPIKeyCredentials";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { PostV1CreateCredentials201 } from "@/app/api/__generated__/models/postV1CreateCredentials201";
|
||||
import { useState } from "react";
|
||||
|
||||
export type APIKeyFormValues = {
|
||||
apiKey: string;
|
||||
@@ -19,32 +19,29 @@ export type APIKeyFormValues = {
|
||||
|
||||
type useAPIKeyCredentialsModalType = {
|
||||
schema: BlockIOCredentialsSubSchema;
|
||||
onClose: () => void;
|
||||
onSuccess: (credentialId: string) => void;
|
||||
};
|
||||
|
||||
export function useAPIKeyCredentialsModal({
|
||||
schema,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: useAPIKeyCredentialsModalType): {
|
||||
form: UseFormReturn<APIKeyFormValues>;
|
||||
isLoading: boolean;
|
||||
provider: string;
|
||||
schemaDescription?: string;
|
||||
onSubmit: (values: APIKeyFormValues) => Promise<void>;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
} {
|
||||
const { toast } = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutateAsync: createCredentials, isPending: isCreatingCredentials } =
|
||||
usePostV1CreateCredentials({
|
||||
mutation: {
|
||||
onSuccess: async (response) => {
|
||||
const credentialId = (response.data as PostV1CreateCredentials201)
|
||||
?.id;
|
||||
onClose();
|
||||
onSuccess: async () => {
|
||||
form.reset();
|
||||
setIsOpen(false);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Credentials created successfully",
|
||||
@@ -54,10 +51,6 @@ export function useAPIKeyCredentialsModal({
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: getGetV1ListCredentialsQueryKey(),
|
||||
});
|
||||
|
||||
if (credentialId && onSuccess) {
|
||||
onSuccess(credentialId);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
@@ -107,5 +100,7 @@ export function useAPIKeyCredentialsModal({
|
||||
provider: schema.credentials_provider[0],
|
||||
schemaDescription: schema.description,
|
||||
onSubmit,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { providerIcons, toDisplayName } from "../../helpers";
|
||||
import { useOAuthCredentialModal } from "./useOAuthCredentialModal";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
type OAuthCredentialModalProps = {
|
||||
provider: string;
|
||||
};
|
||||
|
||||
export const OAuthCredentialModal = ({
|
||||
provider,
|
||||
}: OAuthCredentialModalProps) => {
|
||||
const Icon = providerIcons[provider];
|
||||
const { handleOAuthLogin, loading, error, onClose, open, setOpen } =
|
||||
useOAuthCredentialModal({
|
||||
provider,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
title={`Waiting on ${toDisplayName(provider)} sign-in process...`}
|
||||
controlled={{
|
||||
isOpen: open,
|
||||
set: (isOpen) => {
|
||||
if (!isOpen) setOpen(false);
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<p className="text-sm text-zinc-600">
|
||||
Complete the sign-in process in the pop-up window.
|
||||
<br />
|
||||
Closing this dialog will cancel the sign-in process.
|
||||
</p>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-fit"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
handleOAuthLogin();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
Add OAuth2
|
||||
</Button>
|
||||
{error && (
|
||||
<div className="mt-2 flex w-fit items-center rounded-full bg-red-50 p-1 px-3 ring-1 ring-red-600">
|
||||
<Text variant="small" className="!text-red-600">
|
||||
{error as string}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
getGetV1ListCredentialsQueryKey,
|
||||
useGetV1InitiateOauthFlow,
|
||||
usePostV1ExchangeOauthCodeForTokens,
|
||||
} from "@/app/api/__generated__/endpoints/integrations/integrations";
|
||||
import { LoginResponse } from "@/app/api/__generated__/models/loginResponse";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
type useOAuthCredentialModalProps = {
|
||||
provider: string;
|
||||
scopes?: string[];
|
||||
};
|
||||
|
||||
export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
|
||||
| {
|
||||
success: true;
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
message: string;
|
||||
}
|
||||
);
|
||||
|
||||
export const useOAuthCredentialModal = ({
|
||||
provider,
|
||||
scopes,
|
||||
}: useOAuthCredentialModalProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [oAuthPopupController, setOAuthPopupController] =
|
||||
useState<AbortController | null>(null);
|
||||
const [oAuthError, setOAuthError] = useState<string | null>(null);
|
||||
const [isOAuth2FlowInProgress, setOAuth2FlowInProgress] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
refetch: initiateOauthFlow,
|
||||
isRefetching: isInitiatingOauthFlow,
|
||||
isRefetchError: initiatingOauthFlowError,
|
||||
} = useGetV1InitiateOauthFlow(
|
||||
provider,
|
||||
{
|
||||
scopes: scopes?.join(","),
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: false,
|
||||
select: (res) => {
|
||||
return res.data as LoginResponse;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
mutateAsync: oAuthCallback,
|
||||
isPending: isOAuthCallbackPending,
|
||||
error: oAuthCallbackError,
|
||||
} = usePostV1ExchangeOauthCodeForTokens({
|
||||
mutation: {
|
||||
onSuccess: (data) => {
|
||||
console.log("OAuth callback successful", data);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV1ListCredentialsQueryKey(),
|
||||
});
|
||||
setOpen(false);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Credential added successfully",
|
||||
variant: "default",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
const { data } = await initiateOauthFlow();
|
||||
if (!data || !data.login_url || !data.state_token) {
|
||||
toast({
|
||||
title: "Failed to initiate OAuth flow",
|
||||
variant: "destructive",
|
||||
});
|
||||
setOAuthError(
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? (data.detail as string)
|
||||
: "Failed to initiate OAuth flow",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(true);
|
||||
setOAuth2FlowInProgress(true);
|
||||
|
||||
const { login_url, state_token } = data;
|
||||
|
||||
const popup = window.open(login_url, "_blank", "popup=true");
|
||||
|
||||
if (!popup) {
|
||||
throw new Error(
|
||||
"Failed to open popup window. Please allow popups for this site.",
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setOAuthPopupController(controller);
|
||||
|
||||
controller.signal.onabort = () => {
|
||||
console.debug("OAuth flow aborted");
|
||||
popup.close();
|
||||
};
|
||||
|
||||
const handleMessage = async (e: MessageEvent<OAuthPopupResultMessage>) => {
|
||||
console.log("inside handleMessage");
|
||||
console.debug("Message received:", e.data);
|
||||
if (
|
||||
typeof e.data != "object" ||
|
||||
!("message_type" in e.data) ||
|
||||
e.data.message_type !== "oauth_popup_result"
|
||||
) {
|
||||
console.debug("Ignoring irrelevant message");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.data.success) {
|
||||
console.error("OAuth flow failed:", e.data.message);
|
||||
setOAuthError(`OAuth flow failed: ${e.data.message}`);
|
||||
setOAuth2FlowInProgress(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.data.state !== state_token) {
|
||||
console.error("Invalid state token received");
|
||||
setOAuthError("Invalid state token received");
|
||||
setOAuth2FlowInProgress(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug("Processing OAuth callback");
|
||||
await oAuthCallback({
|
||||
provider,
|
||||
data: {
|
||||
code: e.data.code,
|
||||
state_token: e.data.state,
|
||||
},
|
||||
});
|
||||
|
||||
console.debug("OAuth callback processed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error in OAuth callback:", error);
|
||||
setOAuthError(
|
||||
`Error in OAuth callback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
console.debug("Finalizing OAuth flow");
|
||||
setOAuth2FlowInProgress(false);
|
||||
controller.abort("success");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
console.debug("OAuth flow timed out");
|
||||
controller.abort("timeout");
|
||||
setOAuth2FlowInProgress(false);
|
||||
setOAuthError("OAuth flow timed out");
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
oAuthPopupController?.abort("canceled");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return {
|
||||
handleOAuthLogin,
|
||||
loading:
|
||||
isOAuth2FlowInProgress || isOAuthCallbackPending || isInitiatingOauthFlow,
|
||||
error: oAuthError || initiatingOauthFlowError || oAuthCallbackError,
|
||||
onClose,
|
||||
open,
|
||||
setOpen,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { Form, FormField } from "@/components/__legacy__/ui/form";
|
||||
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types";
|
||||
import { usePasswordCredentialModal } from "./usePasswordCredentialModal";
|
||||
import { toDisplayName } from "../../helpers";
|
||||
import { UserIcon } from "@phosphor-icons/react";
|
||||
|
||||
type Props = {
|
||||
schema: BlockIOCredentialsSubSchema;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
export function PasswordCredentialsModal({ schema, provider }: Props) {
|
||||
const {
|
||||
credentials,
|
||||
isCredentialListLoading,
|
||||
form,
|
||||
onSubmit,
|
||||
open,
|
||||
setOpen,
|
||||
} = usePasswordCredentialModal({ schema });
|
||||
if (!credentials || isCredentialListLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
title={`Add new username & password for ${toDisplayName(provider)}`}
|
||||
controlled={{
|
||||
isOpen: open,
|
||||
set: (isOpen) => {
|
||||
if (!isOpen) setOpen(false);
|
||||
},
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
styling={{
|
||||
maxWidth: "25rem",
|
||||
}}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-2 pt-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="username"
|
||||
label="Username"
|
||||
type="text"
|
||||
placeholder="Enter username..."
|
||||
size="small"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter password..."
|
||||
size="small"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="title"
|
||||
label="Name"
|
||||
type="text"
|
||||
placeholder="Enter a name for this user login..."
|
||||
size="small"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" size="small" className="min-w-68">
|
||||
Save & use this user login
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-fit"
|
||||
size="small"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<UserIcon className="size-4" />
|
||||
Add username & password
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from "react";
|
||||
import { useCredentialField } from "../../useCredentialField";
|
||||
import z from "zod";
|
||||
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
getGetV1ListCredentialsQueryKey,
|
||||
usePostV1CreateCredentials,
|
||||
} from "@/app/api/__generated__/endpoints/integrations/integrations";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
type usePasswordCredentialModalType = {
|
||||
schema: BlockIOCredentialsSubSchema;
|
||||
};
|
||||
|
||||
export const usePasswordCredentialModal = ({
|
||||
schema,
|
||||
}: usePasswordCredentialModalType) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { credentials, isCredentialListLoading } = useCredentialField({
|
||||
credentialSchema: schema,
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
title: z.string().min(1, "Name is required"),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
title: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createCredentials } = usePostV1CreateCredentials({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Credentials created successfully",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: getGetV1ListCredentialsQueryKey(),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
createCredentials({
|
||||
provider: schema.credentials_provider[0],
|
||||
data: {
|
||||
provider: schema.credentials_provider[0],
|
||||
type: "user_password",
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
title: values.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
credentials,
|
||||
isCredentialListLoading,
|
||||
onSubmit,
|
||||
open,
|
||||
setOpen,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useGetV1ListCredentials } from "@/app/api/__generated__/endpoints/integrations/integrations";
|
||||
import { CredentialsMetaResponse } from "@/app/api/__generated__/models/credentialsMetaResponse";
|
||||
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
|
||||
import { useState } from "react";
|
||||
import { filterCredentialsByProvider } from "./helpers";
|
||||
|
||||
export const useCredentialField = ({
|
||||
@@ -9,8 +8,6 @@ export const useCredentialField = ({
|
||||
}: {
|
||||
credentialSchema: BlockIOCredentialsSubSchema; // Here we are using manual typing, we need to fix it with automatic one
|
||||
}) => {
|
||||
const [isAPIKeyModalOpen, setIsAPIKeyModalOpen] = useState(false);
|
||||
|
||||
// Fetch all the credentials from the backend
|
||||
// We will save it in cache for 10 min, if user edits the credential, we will invalidate the cache
|
||||
// Whenever user adds a block, we filter the credentials list and check if this block's provider is in the list
|
||||
@@ -26,6 +23,8 @@ export const useCredentialField = ({
|
||||
|
||||
const supportsApiKey = credentialSchema.credentials_types.includes("api_key");
|
||||
const supportsOAuth2 = credentialSchema.credentials_types.includes("oauth2");
|
||||
const supportsUserPassword =
|
||||
credentialSchema.credentials_types.includes("user_password");
|
||||
|
||||
const credentialProviders = credentialSchema.credentials_provider;
|
||||
const { credentials: filteredCredentials, exists: credentialsExists } =
|
||||
@@ -36,8 +35,7 @@ export const useCredentialField = ({
|
||||
isCredentialListLoading,
|
||||
supportsApiKey,
|
||||
supportsOAuth2,
|
||||
isAPIKeyModalOpen,
|
||||
setIsAPIKeyModalOpen,
|
||||
supportsUserPassword,
|
||||
credentialsExists,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
toDisplayName,
|
||||
} from "../fields/CredentialField/helpers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BlockUIType } from "@/lib/autogpt-server-api";
|
||||
|
||||
const FieldTemplate: React.FC<FieldTemplateProps> = ({
|
||||
id,
|
||||
@@ -65,6 +66,10 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
|
||||
|
||||
const { displayType, colorClass } = getTypeDisplayInfo(schema);
|
||||
|
||||
if (formContext.uiType === BlockUIType.NOTE) {
|
||||
return <div className="w-full space-y-1">{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 w-[400px] space-y-1">
|
||||
{label && schema.type && (
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { InputType, mapJsonSchemaTypeToInputType } from "../helpers";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { BlockUIType } from "@/lib/autogpt-server-api/types";
|
||||
|
||||
export const TextInputWidget = (props: WidgetProps) => {
|
||||
const { schema } = props;
|
||||
const { schema, formContext } = props;
|
||||
const { uiType } = formContext as { uiType: BlockUIType };
|
||||
|
||||
const mapped = mapJsonSchemaTypeToInputType(schema);
|
||||
|
||||
type InputConfig = {
|
||||
@@ -50,6 +53,25 @@ export const TextInputWidget = (props: WidgetProps) => {
|
||||
return props.onChange(config.handleChange(v));
|
||||
};
|
||||
|
||||
if (uiType === BlockUIType.NOTE) {
|
||||
return (
|
||||
<Input
|
||||
id={props.id}
|
||||
hideLabel={true}
|
||||
type={"textarea"}
|
||||
label={""}
|
||||
size="small"
|
||||
wrapperClassName="mb-0"
|
||||
value={props.value ?? ""}
|
||||
className="!h-[230px] resize-none rounded-none border-none bg-transparent p-0 placeholder:text-black/60 focus:ring-0"
|
||||
onChange={handleChange}
|
||||
placeholder={"Write your note here..."}
|
||||
required={props.required}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={props.id}
|
||||
@@ -59,7 +81,7 @@ export const TextInputWidget = (props: WidgetProps) => {
|
||||
size="small"
|
||||
wrapperClassName="mb-0"
|
||||
value={props.value ?? ""}
|
||||
onChange={handleChange as any}
|
||||
onChange={handleChange}
|
||||
placeholder={schema.placeholder || config.placeholder}
|
||||
required={props.required}
|
||||
disabled={props.disabled}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { BlockMenuContent } from "../BlockMenuContent/BlockMenuContent";
|
||||
import { ControlPanelButton } from "../ControlPanelButton";
|
||||
import { useBlockMenu } from "./useBlockMenu";
|
||||
import { BlockMenuStateProvider } from "../block-menu-provider";
|
||||
import { LegoIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface BlockMenuProps {
|
||||
@@ -49,9 +48,7 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
|
||||
className="absolute h-[80vh] w-[46.625rem] overflow-hidden rounded-[1rem] border-none p-0 shadow-[0_2px_6px_0_rgba(0,0,0,0.05)]"
|
||||
data-id="blocks-control-popover-content"
|
||||
>
|
||||
<BlockMenuStateProvider>
|
||||
<BlockMenuContent />
|
||||
</BlockMenuStateProvider>
|
||||
<BlockMenuContent />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { useBlockMenuContext } from "../block-menu-provider";
|
||||
import { BlockMenuSearchBar } from "../BlockMenuSearchBar/BlockMenuSearchBar";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { BlockMenuDefault } from "../BlockMenuDefault/BlockMenuDefault";
|
||||
import { BlockMenuSearch } from "../BlockMenuSearch/BlockMenuSearch";
|
||||
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
|
||||
|
||||
export const BlockMenuContent = () => {
|
||||
const { searchQuery } = useBlockMenuContext();
|
||||
const { searchQuery } = useBlockMenuStore();
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<BlockMenuSearchBar />
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React from "react";
|
||||
import { DefaultStateType, useBlockMenuContext } from "../block-menu-provider";
|
||||
import { AllBlocksContent } from "../AllBlocksContent/AllBlocksContent";
|
||||
import { PaginatedBlocksContent } from "../PaginatedBlocksContent/PaginatedBlocksContent";
|
||||
import { IntegrationsContent } from "../IntegrationsContent/IntegrationsContent";
|
||||
import { MarketplaceAgentsContent } from "../MarketplaceAgentsContent/MarketplaceAgentsContent";
|
||||
import { MyAgentsContent } from "../MyAgentsContent/MyAgentsContent";
|
||||
import { SuggestionContent } from "../SuggestionContent/SuggestionContent";
|
||||
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
|
||||
import { DefaultStateType } from "../types";
|
||||
|
||||
export const BlockMenuDefaultContent = () => {
|
||||
const { defaultState } = useBlockMenuContext();
|
||||
const { defaultState } = useBlockMenuStore();
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 overflow-hidden">
|
||||
|
||||
@@ -1,12 +1,101 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { useBlockMenuSearch } from "./useBlockMenuSearch";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
|
||||
import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
|
||||
import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
|
||||
import { Block } from "../Block";
|
||||
import { UGCAgentBlock } from "../UGCAgentBlock";
|
||||
import { getSearchItemType } from "./helper";
|
||||
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
|
||||
import { blockMenuContainerStyle } from "../style";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NoSearchResult } from "../NoSearchResult";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
|
||||
export const BlockMenuSearch = () => {
|
||||
const {
|
||||
allSearchData,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
searchLoading,
|
||||
} = useBlockMenuSearch();
|
||||
const { searchQuery } = useBlockMenuStore();
|
||||
const addBlock = useNodeStore((state) => state.addBlock);
|
||||
|
||||
if (searchLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
blockMenuContainerStyle,
|
||||
"flex items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<LoadingSpinner className="size-13" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (allSearchData.length === 0) {
|
||||
return <NoSearchResult />;
|
||||
}
|
||||
|
||||
return (
|
||||
// This is just a temporary text, will content inside in it [in follow-up prs]
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Text variant="h3" className="text-green-300">
|
||||
This is the block menu search
|
||||
</Text>
|
||||
<div className={blockMenuContainerStyle}>
|
||||
<Text variant="body-medium">Search results</Text>
|
||||
<InfiniteScroll
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
loader={<LoadingSpinner className="size-13" />}
|
||||
className="space-y-2.5"
|
||||
>
|
||||
{allSearchData.map((item: SearchResponseItemsItem, index: number) => {
|
||||
const { type, data } = getSearchItemType(item);
|
||||
// backend give support to these 3 types only [right now] - we need to give support to integration and ai agent types in follow up PRs
|
||||
switch (type) {
|
||||
case "store_agent":
|
||||
return (
|
||||
<MarketplaceAgentBlock
|
||||
key={index}
|
||||
slug={data.slug}
|
||||
highlightedText={searchQuery}
|
||||
title={data.agent_name}
|
||||
image_url={data.agent_image}
|
||||
creator_name={data.creator}
|
||||
number_of_runs={data.runs}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
case "block":
|
||||
return (
|
||||
<Block
|
||||
key={index}
|
||||
title={data.name}
|
||||
highlightedText={searchQuery}
|
||||
description={data.description}
|
||||
onClick={() => addBlock(data)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "library_agent":
|
||||
return (
|
||||
<UGCAgentBlock
|
||||
key={index}
|
||||
title={data.name}
|
||||
highlightedText={searchQuery}
|
||||
image_url={data.image_url}
|
||||
version={data.graph_version}
|
||||
edited_time={data.updated_at}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
|
||||
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
|
||||
|
||||
export const getSearchItemType = (
|
||||
item: SearchResponseItemsItem,
|
||||
):
|
||||
| { type: "store_agent"; data: StoreAgent }
|
||||
| { type: "library_agent"; data: LibraryAgent }
|
||||
| { type: "block"; data: BlockInfo } => {
|
||||
if ("slug" in item && "agent_name" in item && "creator" in item) {
|
||||
return { type: "store_agent", data: item as StoreAgent };
|
||||
}
|
||||
|
||||
if ("graph_id" in item && "graph_version" in item && "creator_name" in item) {
|
||||
return { type: "library_agent", data: item as LibraryAgent };
|
||||
}
|
||||
|
||||
if ("inputSchema" in item && "outputSchema" in item && "uiType" in item) {
|
||||
return { type: "block", data: item as BlockInfo };
|
||||
}
|
||||
|
||||
throw new Error("Unknown item type");
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
|
||||
import { useGetV2BuilderSearchInfinite } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { SearchResponse } from "@/app/api/__generated__/models/searchResponse";
|
||||
|
||||
export const useBlockMenuSearch = () => {
|
||||
const { searchQuery } = useBlockMenuStore();
|
||||
|
||||
const {
|
||||
data: searchData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading: searchLoading,
|
||||
} = useGetV2BuilderSearchInfinite(
|
||||
{
|
||||
page: 1,
|
||||
page_size: 8,
|
||||
search_query: searchQuery,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const pagination = lastPage.data as SearchResponse;
|
||||
const isMore = pagination.more_pages;
|
||||
return isMore ? allPages.length + 1 : undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const allSearchData =
|
||||
searchData?.pages?.flatMap((page) => {
|
||||
const response = page.data as SearchResponse;
|
||||
return response.items;
|
||||
}) ?? [];
|
||||
|
||||
return {
|
||||
allSearchData,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
searchLoading,
|
||||
};
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import { debounce } from "lodash";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useBlockMenuContext } from "../block-menu-provider";
|
||||
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
export const useBlockMenuSearchBar = () => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [localQuery, setLocalQuery] = useState("");
|
||||
const { setSearchQuery, setSearchId, searchId } = useBlockMenuContext();
|
||||
const { setSearchQuery, setSearchId, searchId } = useBlockMenuStore();
|
||||
|
||||
const searchIdRef = useRef(searchId);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React from "react";
|
||||
import { MenuItem } from "../MenuItem";
|
||||
import { DefaultStateType, useBlockMenuContext } from "../block-menu-provider";
|
||||
import { useBlockMenuSidebar } from "./useBlockMenuSidebar";
|
||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
|
||||
import { DefaultStateType } from "../types";
|
||||
|
||||
export const BlockMenuSidebar = () => {
|
||||
const { data, setDefaultState, defaultState, isLoading, isError, error } =
|
||||
useBlockMenuSidebar();
|
||||
const { setIntegration } = useBlockMenuContext();
|
||||
const { setIntegration } = useBlockMenuStore();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-fit space-y-2 px-4 pt-4">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useGetV2GetBuilderItemCounts } from "@/app/api/__generated__/endpoints/default/default";
|
||||
import { useBlockMenuContext } from "../block-menu-provider";
|
||||
import { CountResponse } from "@/app/api/__generated__/models/countResponse";
|
||||
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
|
||||
|
||||
export const useBlockMenuSidebar = () => {
|
||||
const { defaultState, setDefaultState } = useBlockMenuContext();
|
||||
const { defaultState, setDefaultState } = useBlockMenuStore();
|
||||
|
||||
const { data, isLoading, isError, error } = useGetV2GetBuilderItemCounts({
|
||||
query: {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import React, { Fragment } from "react";
|
||||
import { IntegrationBlock } from "../IntergrationBlock";
|
||||
import { useBlockMenuContext } from "../block-menu-provider";
|
||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { useIntegrationBlocks } from "./useIntegrationBlocks";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
|
||||
|
||||
export const IntegrationBlocks = () => {
|
||||
const { integration, setIntegration } = useBlockMenuContext();
|
||||
const { integration, setIntegration } = useBlockMenuStore();
|
||||
const {
|
||||
allBlocks,
|
||||
status,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useGetV2GetBuilderBlocksInfinite } from "@/app/api/__generated__/endpoints/default/default";
|
||||
import { BlockResponse } from "@/app/api/__generated__/models/blockResponse";
|
||||
import { useBlockMenuContext } from "../block-menu-provider";
|
||||
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export const useIntegrationBlocks = () => {
|
||||
const { integration } = useBlockMenuContext();
|
||||
const { integration } = useBlockMenuStore();
|
||||
|
||||
const {
|
||||
data: blocks,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from "react";
|
||||
import { useBlockMenuContext } from "../block-menu-provider";
|
||||
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||
import { IntegrationBlocks } from "../IntegrationBlocks/IntegrationBlocks";
|
||||
import { PaginatedIntegrationList } from "../PaginatedIntegrationList/PaginatedIntegrationList";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
|
||||
|
||||
export const IntegrationsContent = () => {
|
||||
const { integration } = useBlockMenuContext();
|
||||
const { integration } = useBlockMenuStore();
|
||||
|
||||
if (!integration) {
|
||||
return <PaginatedIntegrationList />;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from "react";
|
||||
import { Integration } from "../Integration";
|
||||
import { useBlockMenuContext } from "../block-menu-provider";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { usePaginatedIntegrationList } from "./usePaginatedIntegrationList";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { blockMenuContainerStyle } from "../style";
|
||||
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
|
||||
|
||||
export const PaginatedIntegrationList = () => {
|
||||
const { setIntegration } = useBlockMenuContext();
|
||||
const { setIntegration } = useBlockMenuStore();
|
||||
const {
|
||||
allProviders: providers,
|
||||
providersLoading,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React from "react";
|
||||
import { IntegrationChip } from "../IntegrationChip";
|
||||
import { Block } from "../Block";
|
||||
import { DefaultStateType, useBlockMenuContext } from "../block-menu-provider";
|
||||
import { useSuggestionContent } from "./useSuggestionContent";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { blockMenuContainerStyle } from "../style";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useBlockMenuStore } from "../../../stores/blockMenuStore";
|
||||
import { DefaultStateType } from "../types";
|
||||
|
||||
export const SuggestionContent = () => {
|
||||
const { setIntegration, setDefaultState } = useBlockMenuContext();
|
||||
const { setIntegration, setDefaultState } = useBlockMenuStore();
|
||||
const { data, isLoading, isError, error, refetch } = useSuggestionContent();
|
||||
const addBlock = useNodeStore((state) => state.addBlock);
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, ReactNode, useContext, useState } from "react";
|
||||
|
||||
export enum DefaultStateType {
|
||||
SUGGESTION = "suggestion",
|
||||
ALL_BLOCKS = "all_blocks",
|
||||
INPUT_BLOCKS = "input_blocks",
|
||||
ACTION_BLOCKS = "action_blocks",
|
||||
OUTPUT_BLOCKS = "output_blocks",
|
||||
INTEGRATIONS = "integrations",
|
||||
MARKETPLACE_AGENTS = "marketplace_agents",
|
||||
MY_AGENTS = "my_agents",
|
||||
}
|
||||
|
||||
interface BlockMenuContextType {
|
||||
searchQuery: string;
|
||||
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||
searchId: string | undefined;
|
||||
setSearchId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
defaultState: DefaultStateType;
|
||||
setDefaultState: React.Dispatch<React.SetStateAction<DefaultStateType>>;
|
||||
integration: string | undefined;
|
||||
setIntegration: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
}
|
||||
|
||||
export const BlockMenuContext = createContext<BlockMenuContextType>(
|
||||
{} as BlockMenuContextType,
|
||||
);
|
||||
|
||||
interface BlockMenuStateProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function BlockMenuStateProvider({
|
||||
children,
|
||||
}: BlockMenuStateProviderProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchId, setSearchId] = useState<string | undefined>(undefined);
|
||||
const [defaultState, setDefaultState] = useState<DefaultStateType>(
|
||||
DefaultStateType.SUGGESTION,
|
||||
);
|
||||
const [integration, setIntegration] = useState<string | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<BlockMenuContext.Provider
|
||||
value={{
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchId,
|
||||
setSearchId,
|
||||
defaultState,
|
||||
setDefaultState,
|
||||
integration,
|
||||
setIntegration,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BlockMenuContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBlockMenuContext(): BlockMenuContextType {
|
||||
const context = useContext(BlockMenuContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useBlockMenuContext must be used within a BlockMenuStateProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum DefaultStateType {
|
||||
SUGGESTION = "suggestion",
|
||||
ALL_BLOCKS = "all_blocks",
|
||||
INPUT_BLOCKS = "input_blocks",
|
||||
ACTION_BLOCKS = "action_blocks",
|
||||
OUTPUT_BLOCKS = "output_blocks",
|
||||
INTEGRATIONS = "integrations",
|
||||
MARKETPLACE_AGENTS = "marketplace_agents",
|
||||
MY_AGENTS = "my_agents",
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
||||
import { CustomNodeData } from "./FlowEditor/nodes/CustomNode";
|
||||
import { BlockUIType } from "./types";
|
||||
|
||||
export const convertBlockInfoIntoCustomNodeData = (block: BlockInfo) => {
|
||||
const customNodeData: CustomNodeData = {
|
||||
@@ -8,6 +9,7 @@ export const convertBlockInfoIntoCustomNodeData = (block: BlockInfo) => {
|
||||
description: block.description,
|
||||
inputSchema: block.inputSchema,
|
||||
outputSchema: block.outputSchema,
|
||||
uiType: block.uiType as BlockUIType,
|
||||
};
|
||||
return customNodeData;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Currently I am using it, but we will transfer it to the backend, so we can have automated created types
|
||||
export enum BlockUIType {
|
||||
STANDARD = "Standard",
|
||||
INPUT = "Input",
|
||||
OUTPUT = "Output",
|
||||
NOTE = "Note",
|
||||
WEBHOOK = "Webhook",
|
||||
WEBHOOK_MANUAL = "Webhook (manual)",
|
||||
AGENT = "Agent",
|
||||
AI = "AI",
|
||||
AYRSHARE = "Ayrshare",
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { create } from "zustand";
|
||||
import { DefaultStateType } from "../components/NewBlockMenu/types";
|
||||
|
||||
type BlockMenuStore = {
|
||||
searchQuery: string;
|
||||
searchId: string | undefined;
|
||||
defaultState: DefaultStateType;
|
||||
integration: string | undefined;
|
||||
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSearchId: (id: string | undefined) => void;
|
||||
setDefaultState: (state: DefaultStateType) => void;
|
||||
setIntegration: (integration: string | undefined) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export const useBlockMenuStore = create<BlockMenuStore>((set) => ({
|
||||
searchQuery: "",
|
||||
searchId: undefined,
|
||||
defaultState: DefaultStateType.SUGGESTION,
|
||||
integration: undefined,
|
||||
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setSearchId: (id) => set({ searchId: id }),
|
||||
setDefaultState: (state) => set({ defaultState: state }),
|
||||
setIntegration: (integration) => set({ integration }),
|
||||
reset: () =>
|
||||
set({
|
||||
searchQuery: "",
|
||||
searchId: undefined,
|
||||
defaultState: DefaultStateType.SUGGESTION,
|
||||
integration: undefined,
|
||||
}),
|
||||
}));
|
||||
@@ -1,96 +0,0 @@
|
||||
"use server";
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { loginFormSchema, LoginProvider } from "@/types/auth";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
async function shouldShowOnboarding() {
|
||||
const api = new BackendAPI();
|
||||
return (
|
||||
(await api.isOnboardingEnabled()) &&
|
||||
!(await api.getUserOnboarding()).completedSteps.includes("CONGRATS")
|
||||
);
|
||||
}
|
||||
|
||||
export async function login(
|
||||
values: z.infer<typeof loginFormSchema>,
|
||||
turnstileToken: string,
|
||||
) {
|
||||
return await Sentry.withServerActionInstrumentation("login", {}, async () => {
|
||||
const supabase = await getServerSupabase();
|
||||
const api = new BackendAPI();
|
||||
|
||||
if (!supabase) {
|
||||
redirect("/error");
|
||||
}
|
||||
|
||||
// Verify Turnstile token if provided
|
||||
const success = await verifyTurnstileToken(turnstileToken, "login");
|
||||
if (!success) {
|
||||
return "CAPTCHA verification failed. Please try again.";
|
||||
}
|
||||
|
||||
// We are sure that the values are of the correct type because zod validates the form
|
||||
const { error } = await supabase.auth.signInWithPassword(values);
|
||||
|
||||
if (error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
await api.createUser();
|
||||
|
||||
// Don't onboard if disabled or already onboarded
|
||||
if (await shouldShowOnboarding()) {
|
||||
revalidatePath("/onboarding", "layout");
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
redirect("/");
|
||||
});
|
||||
}
|
||||
|
||||
export async function providerLogin(provider: LoginProvider) {
|
||||
return await Sentry.withServerActionInstrumentation(
|
||||
"providerLogin",
|
||||
{},
|
||||
async () => {
|
||||
const supabase = await getServerSupabase();
|
||||
|
||||
if (!supabase) {
|
||||
redirect("/error");
|
||||
}
|
||||
|
||||
const { data, error } = await supabase!.auth.signInWithOAuth({
|
||||
provider: provider,
|
||||
options: {
|
||||
redirectTo:
|
||||
process.env.AUTH_CALLBACK_URL ??
|
||||
`http://localhost:3000/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
// FIXME: supabase doesn't return the correct error message for this case
|
||||
if (error.message.includes("P0001")) {
|
||||
return "not_allowed";
|
||||
}
|
||||
|
||||
console.error("Error logging in", error);
|
||||
return error.message;
|
||||
}
|
||||
|
||||
// Redirect to the OAuth provider's URL
|
||||
if (data?.url) {
|
||||
redirect(data.url);
|
||||
}
|
||||
|
||||
// Note: api.createUser() and onboarding check happen in the callback handler
|
||||
// after the session is established. See `auth/callback/route.ts`.
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -85,16 +85,18 @@ export default function LoginPage() {
|
||||
/>
|
||||
|
||||
{/* Turnstile CAPTCHA Component */}
|
||||
<Turnstile
|
||||
key={captchaKey}
|
||||
siteKey={turnstile.siteKey}
|
||||
onVerify={turnstile.handleVerify}
|
||||
onExpire={turnstile.handleExpire}
|
||||
onError={turnstile.handleError}
|
||||
setWidgetId={turnstile.setWidgetId}
|
||||
action="login"
|
||||
shouldRender={turnstile.shouldRender}
|
||||
/>
|
||||
{turnstile.shouldRender ? (
|
||||
<Turnstile
|
||||
key={captchaKey}
|
||||
siteKey={turnstile.siteKey}
|
||||
onVerify={turnstile.handleVerify}
|
||||
onExpire={turnstile.handleExpire}
|
||||
onError={turnstile.handleError}
|
||||
setWidgetId={turnstile.setWidgetId}
|
||||
action="login"
|
||||
shouldRender={turnstile.shouldRender}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import { login, providerLogin } from "./actions";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
export function useLoginPage() {
|
||||
@@ -60,18 +59,32 @@ export function useLoginPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const error = await providerLogin(provider);
|
||||
if (error) throw error;
|
||||
setFeedback(null);
|
||||
const response = await fetch("/api/auth/provider", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
if (typeof error === "string" && error.includes("not_allowed")) {
|
||||
setShowNotAllowedModal(true);
|
||||
} else {
|
||||
setFeedback(error || "Failed to start OAuth flow");
|
||||
}
|
||||
resetCaptcha();
|
||||
setIsGoogleLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
if (url) window.location.href = url as string;
|
||||
} catch (error) {
|
||||
resetCaptcha();
|
||||
setIsGoogleLoading(false);
|
||||
const errorString = JSON.stringify(error);
|
||||
if (errorString.includes("not_allowed")) {
|
||||
setShowNotAllowedModal(true);
|
||||
} else {
|
||||
setFeedback(errorString);
|
||||
}
|
||||
setFeedback(
|
||||
error instanceof Error ? error.message : "Failed to start OAuth flow",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,21 +112,49 @@ export function useLoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = await login(data, turnstile.token as string);
|
||||
await supabase?.auth.refreshSession();
|
||||
setIsLoading(false);
|
||||
if (error) {
|
||||
toast({
|
||||
title: error,
|
||||
variant: "destructive",
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
turnstileToken: turnstile.token,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: result?.error || "Login failed",
|
||||
variant: "destructive",
|
||||
});
|
||||
setIsLoading(false);
|
||||
resetCaptcha();
|
||||
turnstile.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
await supabase?.auth.refreshSession();
|
||||
setIsLoading(false);
|
||||
setFeedback(null);
|
||||
|
||||
const next =
|
||||
(result?.next as string) || (result?.onboarding ? "/onboarding" : "/");
|
||||
if (next) router.push(next);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unexpected error during login",
|
||||
variant: "destructive",
|
||||
});
|
||||
setIsLoading(false);
|
||||
resetCaptcha();
|
||||
// Always reset the turnstile on any error
|
||||
turnstile.reset();
|
||||
return;
|
||||
}
|
||||
setFeedback(null);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
|
||||
import { signupFormSchema } from "@/types/auth";
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
|
||||
export async function signup(
|
||||
values: z.infer<typeof signupFormSchema>,
|
||||
turnstileToken: string,
|
||||
) {
|
||||
"use server";
|
||||
return await Sentry.withServerActionInstrumentation(
|
||||
"signup",
|
||||
{},
|
||||
async () => {
|
||||
const supabase = await getServerSupabase();
|
||||
|
||||
if (!supabase) {
|
||||
redirect("/error");
|
||||
}
|
||||
|
||||
// Verify Turnstile token if provided
|
||||
const success = await verifyTurnstileToken(turnstileToken, "signup");
|
||||
if (!success) {
|
||||
return "CAPTCHA verification failed. Please try again.";
|
||||
}
|
||||
|
||||
// We are sure that the values are of the correct type because zod validates the form
|
||||
const { data, error } = await supabase.auth.signUp(values);
|
||||
|
||||
if (error) {
|
||||
console.error("Error signing up", error);
|
||||
// FIXME: supabase doesn't return the correct error message for this case
|
||||
if (error.message.includes("P0001")) {
|
||||
return "not_allowed";
|
||||
}
|
||||
if (error.code === "user_already_exists") {
|
||||
return "user_already_exists";
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (data.session) {
|
||||
await supabase.auth.setSession(data.session);
|
||||
}
|
||||
// Don't onboard if disabled
|
||||
if (await new BackendAPI().isOnboardingEnabled()) {
|
||||
revalidatePath("/onboarding", "layout");
|
||||
redirect("/onboarding");
|
||||
}
|
||||
revalidatePath("/", "layout");
|
||||
redirect("/");
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import { providerLogin } from "../login/actions";
|
||||
import { signup } from "./actions";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
export function useSignupPage() {
|
||||
@@ -61,18 +59,36 @@ export function useSignupPage() {
|
||||
resetCaptcha();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch("/api/auth/provider", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider }),
|
||||
});
|
||||
|
||||
const error = await providerLogin(provider);
|
||||
if (error) {
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
setIsGoogleLoading(false);
|
||||
resetCaptcha();
|
||||
toast({
|
||||
title: error || "Failed to start OAuth flow",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
if (url) window.location.href = url as string;
|
||||
setFeedback(null);
|
||||
} catch (error) {
|
||||
setIsGoogleLoading(false);
|
||||
resetCaptcha();
|
||||
toast({
|
||||
title: error,
|
||||
title:
|
||||
error instanceof Error ? error.message : "Failed to start OAuth flow",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setFeedback(null);
|
||||
}
|
||||
|
||||
async function handleSignup(data: z.infer<typeof signupFormSchema>) {
|
||||
@@ -100,26 +116,56 @@ export function useSignupPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = await signup(data, turnstile.token as string);
|
||||
setIsLoading(false);
|
||||
if (error) {
|
||||
if (error === "user_already_exists") {
|
||||
setFeedback("User with this email already exists");
|
||||
turnstile.reset();
|
||||
return;
|
||||
} else if (error === "not_allowed") {
|
||||
setShowNotAllowedModal(true);
|
||||
} else {
|
||||
try {
|
||||
const response = await fetch("/api/auth/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
confirmPassword: data.confirmPassword,
|
||||
agreeToTerms: data.agreeToTerms,
|
||||
turnstileToken: turnstile.token,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
setIsLoading(false);
|
||||
|
||||
if (!response.ok) {
|
||||
if (result?.error === "user_already_exists") {
|
||||
setFeedback("User with this email already exists");
|
||||
turnstile.reset();
|
||||
return;
|
||||
}
|
||||
if (result?.error === "not_allowed") {
|
||||
setShowNotAllowedModal(true);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: error,
|
||||
title: result?.error || "Signup failed",
|
||||
variant: "destructive",
|
||||
});
|
||||
resetCaptcha();
|
||||
turnstile.reset();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
|
||||
setFeedback(null);
|
||||
const next = (result?.next as string) || "/";
|
||||
router.push(next);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
toast({
|
||||
title:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unexpected error during signup",
|
||||
variant: "destructive",
|
||||
});
|
||||
resetCaptcha();
|
||||
turnstile.reset();
|
||||
}
|
||||
setFeedback(null);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
66
autogpt_platform/frontend/src/app/api/auth/login/route.ts
Normal file
66
autogpt_platform/frontend/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { loginFormSchema } from "@/types/auth";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { shouldShowOnboarding } from "../../helpers";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const parsed = loginFormSchema.safeParse({
|
||||
email: body?.email,
|
||||
password: body?.password,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email or password" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const turnstileToken: string | undefined = body?.turnstileToken;
|
||||
|
||||
// Verify Turnstile token if provided
|
||||
const captchaOk = await verifyTurnstileToken(turnstileToken ?? "", "login");
|
||||
if (!captchaOk) {
|
||||
return NextResponse.json(
|
||||
{ error: "CAPTCHA verification failed. Please try again." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = await getServerSupabase();
|
||||
if (!supabase) {
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication service unavailable" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword(parsed.data);
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
const api = new BackendAPI();
|
||||
await api.createUser();
|
||||
|
||||
const onboarding = await shouldShowOnboarding();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
onboarding,
|
||||
next: onboarding ? "/onboarding" : "/",
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to login. Please try again." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
49
autogpt_platform/frontend/src/app/api/auth/provider/route.ts
Normal file
49
autogpt_platform/frontend/src/app/api/auth/provider/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
|
||||
import { NextResponse } from "next/server";
|
||||
import { LoginProvider } from "@/types/auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const provider: LoginProvider | undefined = body?.provider;
|
||||
const redirectTo: string | undefined = body?.redirectTo;
|
||||
|
||||
if (!provider) {
|
||||
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = await getServerSupabase();
|
||||
if (!supabase) {
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication service unavailable" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: {
|
||||
redirectTo:
|
||||
redirectTo ||
|
||||
process.env.AUTH_CALLBACK_URL ||
|
||||
`http://localhost:3000/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
// FIXME: supabase doesn't return the correct error message for this case
|
||||
if (error.message.includes("P0001")) {
|
||||
return NextResponse.json({ error: "not_allowed" }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: data?.url });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to initiate OAuth" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
78
autogpt_platform/frontend/src/app/api/auth/signup/route.ts
Normal file
78
autogpt_platform/frontend/src/app/api/auth/signup/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { signupFormSchema } from "@/types/auth";
|
||||
import { shouldShowOnboarding } from "../../helpers";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const parsed = signupFormSchema.safeParse({
|
||||
email: body?.email,
|
||||
password: body?.password,
|
||||
confirmPassword: body?.confirmPassword,
|
||||
agreeToTerms: body?.agreeToTerms,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid signup payload" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const turnstileToken: string | undefined = body?.turnstileToken;
|
||||
|
||||
const captchaOk = await verifyTurnstileToken(
|
||||
turnstileToken ?? "",
|
||||
"signup",
|
||||
);
|
||||
if (!captchaOk) {
|
||||
return NextResponse.json(
|
||||
{ error: "CAPTCHA verification failed. Please try again." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = await getServerSupabase();
|
||||
if (!supabase) {
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication service unavailable" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.signUp(parsed.data);
|
||||
|
||||
if (error) {
|
||||
// FIXME: supabase doesn't return the correct error message for this case
|
||||
if (error.message.includes("P0001")) {
|
||||
return NextResponse.json({ error: "not_allowed" }, { status: 403 });
|
||||
}
|
||||
if ((error as any).code === "user_already_exists") {
|
||||
return NextResponse.json(
|
||||
{ error: "user_already_exists" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
if (data.session) {
|
||||
await supabase.auth.setSession(data.session);
|
||||
}
|
||||
|
||||
const isOnboardingEnabled = await shouldShowOnboarding();
|
||||
const next = isOnboardingEnabled ? "/onboarding" : "/";
|
||||
|
||||
return NextResponse.json({ success: true, next });
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to sign up. Please try again." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
|
||||
/**
|
||||
* Narrow an orval response to its success payload if and only if it is a `200` status with OK shape.
|
||||
*
|
||||
@@ -23,3 +25,11 @@ export function okData<T>(res: unknown): T | undefined {
|
||||
|
||||
return (res as { data: T }).data;
|
||||
}
|
||||
|
||||
export async function shouldShowOnboarding() {
|
||||
const api = new BackendAPI();
|
||||
const isEnabled = await api.isOnboardingEnabled();
|
||||
const onboarding = await api.getUserOnboarding();
|
||||
const isCompleted = onboarding.completedSteps.includes("CONGRATS");
|
||||
return isEnabled && !isCompleted;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"/api/integrations/{provider}/login": {
|
||||
"get": {
|
||||
"tags": ["v1", "integrations"],
|
||||
"summary": "Login",
|
||||
"operationId": "getV1Login",
|
||||
"summary": "Initiate OAuth flow",
|
||||
"operationId": "getV1Initiate oauth flow",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -60,8 +60,8 @@
|
||||
"/api/integrations/{provider}/callback": {
|
||||
"post": {
|
||||
"tags": ["v1", "integrations"],
|
||||
"summary": "Callback",
|
||||
"operationId": "postV1Callback",
|
||||
"summary": "Exchange OAuth code for tokens",
|
||||
"operationId": "postV1Exchange oauth code for tokens",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -79,7 +79,9 @@
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/Body_postV1Callback" }
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Body_postV1Exchange_oauth_code_for_tokens"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3443,19 +3445,68 @@
|
||||
}
|
||||
},
|
||||
"/api/builder/search": {
|
||||
"post": {
|
||||
"get": {
|
||||
"tags": ["v2", "store", "private"],
|
||||
"summary": "Builder search",
|
||||
"description": "Search for blocks (including integrations), marketplace agents, and user library agents.",
|
||||
"operationId": "postV2Builder search",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/SearchRequest" }
|
||||
"operationId": "getV2Builder search",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "search_query",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Search Query"
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "filter",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{ "type": "array", "items": { "type": "string" } },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Filter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "search_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Search Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "by_creator",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{ "type": "array", "items": { "type": "string" } },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "By Creator"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": { "type": "integer", "default": 1, "title": "Page" }
|
||||
},
|
||||
{
|
||||
"name": "page_size",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": { "type": "integer", "default": 50, "title": "Page Size" }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -3476,8 +3527,7 @@
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
}
|
||||
},
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/builder/counts": {
|
||||
@@ -5062,7 +5112,7 @@
|
||||
"required": ["blocks", "pagination"],
|
||||
"title": "BlockResponse"
|
||||
},
|
||||
"Body_postV1Callback": {
|
||||
"Body_postV1Exchange_oauth_code_for_tokens": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
@@ -5072,7 +5122,7 @@
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["code", "state_token"],
|
||||
"title": "Body_postV1Callback"
|
||||
"title": "Body_postV1Exchange oauth code for tokens"
|
||||
},
|
||||
"Body_postV1Execute_graph_agent": {
|
||||
"properties": {
|
||||
@@ -7342,53 +7392,6 @@
|
||||
"required": ["name", "cron", "inputs"],
|
||||
"title": "ScheduleCreationRequest"
|
||||
},
|
||||
"SearchRequest": {
|
||||
"properties": {
|
||||
"search_query": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Search Query"
|
||||
},
|
||||
"filter": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"blocks",
|
||||
"integrations",
|
||||
"marketplace_agents",
|
||||
"my_agents"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Filter"
|
||||
},
|
||||
"by_creator": {
|
||||
"anyOf": [
|
||||
{ "items": { "type": "string" }, "type": "array" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "By Creator"
|
||||
},
|
||||
"search_id": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Search Id"
|
||||
},
|
||||
"page": {
|
||||
"anyOf": [{ "type": "integer" }, { "type": "null" }],
|
||||
"title": "Page"
|
||||
},
|
||||
"page_size": {
|
||||
"anyOf": [{ "type": "integer" }, { "type": "null" }],
|
||||
"title": "Page Size"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "SearchRequest"
|
||||
},
|
||||
"SearchResponse": {
|
||||
"properties": {
|
||||
"items": {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import CredentialsProvider from "@/providers/agent-credentials/credentials-provider";
|
||||
import { SentryUserTracker } from "@/components/monitor/SentryUserTracker";
|
||||
|
||||
export function Providers({ children, ...props }: ThemeProviderProps) {
|
||||
const queryClient = getQueryClient();
|
||||
@@ -20,6 +21,7 @@ export function Providers({ children, ...props }: ThemeProviderProps) {
|
||||
<NuqsAdapter>
|
||||
<NextThemesProvider {...props}>
|
||||
<BackendAPIProvider>
|
||||
<SentryUserTracker />
|
||||
<CredentialsProvider>
|
||||
<LaunchDarklyProvider>
|
||||
<OnboardingProvider>
|
||||
|
||||
@@ -18,15 +18,10 @@ export function AgentActivityDropdown() {
|
||||
recentFailures,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
isAgentActivityEnabled,
|
||||
} = useAgentActivityDropdown();
|
||||
|
||||
const activeCount = activeExecutions.length;
|
||||
|
||||
if (!isAgentActivityEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
categorizeExecutions,
|
||||
handleExecutionUpdate,
|
||||
} from "./helpers";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
type AgentInfoMap = Map<
|
||||
string,
|
||||
@@ -19,7 +18,6 @@ type AgentInfoMap = Map<
|
||||
>;
|
||||
|
||||
export function useAgentActivityDropdown() {
|
||||
const isAgentActivityEnabled = useGetFlag(Flag.AGENT_ACTIVITY);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [api] = useState(() => new BackendAPI());
|
||||
@@ -162,6 +160,5 @@ export function useAgentActivityDropdown() {
|
||||
error: executionsError || agentsError,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
isAgentActivityEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function MobileNavBar({
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Open menu"
|
||||
className="min-w-auto flex !w-[3.75rem] items-center justify-center md:hidden"
|
||||
className="min-w-auto flex !min-w-[3.75rem] items-center justify-center md:hidden"
|
||||
data-testid="mobile-nav-bar-trigger"
|
||||
>
|
||||
{isOpen ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { IconAutoGPTLogo, IconType } from "@/components/__legacy__/ui/icons";
|
||||
import Wallet from "../../../__legacy__/Wallet";
|
||||
import Wallet from "../../../../app/(no-navbar)/onboarding/components/Wallet/Wallet";
|
||||
import { AccountMenu } from "./AccountMenu/AccountMenu";
|
||||
import { LoginButton } from "./LoginButton";
|
||||
import { MobileNavBar } from "./MobileNavbar/MobileNavBar";
|
||||
@@ -27,7 +27,7 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="sticky top-0 z-40 inline-flex h-16 w-full items-center border border-white/50 bg-[#f3f4f6]/20 p-3 backdrop-blur-[26px]">
|
||||
<nav className="sticky top-0 z-40 inline-flex h-[60px] w-full items-center border border-white/50 bg-[#f3f4f6]/20 p-3 backdrop-blur-[26px]">
|
||||
{/* Left section */}
|
||||
<div className="hidden flex-1 items-center gap-3 md:flex md:gap-5">
|
||||
{isLoggedIn
|
||||
@@ -40,7 +40,7 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
|
||||
</div>
|
||||
|
||||
{/* Centered logo */}
|
||||
<div className="absolute left-16 top-1/2 h-auto w-[5.5rem] -translate-x-1/2 -translate-y-1/2 md:left-1/2">
|
||||
<div className="static h-auto w-[4.5rem] md:absolute md:left-1/2 md:top-1/2 md:w-[5.5rem] md:-translate-x-1/2 md:-translate-y-1/2">
|
||||
<IconAutoGPTLogo className="h-full w-full" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
|
||||
/**
|
||||
* SentryUserTracker component sets user context in Sentry for error tracking.
|
||||
* This component should be placed high in the component tree to ensure user
|
||||
* context is available for all error reports.
|
||||
*
|
||||
* It automatically:
|
||||
* - Sets user context when a user logs in
|
||||
* - Clears user context when a user logs out
|
||||
* - Updates context when user data changes
|
||||
*/
|
||||
export function SentryUserTracker() {
|
||||
const { user, isUserLoading } = useSupabase();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
// Wait until user loading is complete before setting user context
|
||||
if (isUserLoading) return;
|
||||
|
||||
// Set user context for Sentry error tracking
|
||||
Sentry.setUser({
|
||||
id: user.id,
|
||||
email: user.email ?? undefined,
|
||||
// Add custom attributes
|
||||
...(user.role && { role: user.role }),
|
||||
});
|
||||
} else {
|
||||
// Always clear user context when user is null, regardless of loading state
|
||||
// This ensures logout properly clears the context immediately
|
||||
Sentry.setUser(null);
|
||||
}
|
||||
}, [user, isUserLoading]);
|
||||
|
||||
// This component doesn't render anything
|
||||
return null;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { useFlags } from "launchdarkly-react-client-sdk";
|
||||
|
||||
export enum Flag {
|
||||
BETA_BLOCKS = "beta-blocks",
|
||||
AGENT_ACTIVITY = "agent-activity",
|
||||
NEW_BLOCK_MENU = "new-block-menu",
|
||||
NEW_AGENT_RUNS = "new-agent-runs",
|
||||
GRAPH_SEARCH = "graph-search",
|
||||
@@ -18,7 +17,6 @@ export enum Flag {
|
||||
|
||||
export type FlagValues = {
|
||||
[Flag.BETA_BLOCKS]: string[];
|
||||
[Flag.AGENT_ACTIVITY]: boolean;
|
||||
[Flag.NEW_BLOCK_MENU]: boolean;
|
||||
[Flag.NEW_AGENT_RUNS]: boolean;
|
||||
[Flag.GRAPH_SEARCH]: boolean;
|
||||
@@ -33,7 +31,6 @@ const isPwMockEnabled = process.env.NEXT_PUBLIC_PW_TEST === "true";
|
||||
|
||||
const mockFlags = {
|
||||
[Flag.BETA_BLOCKS]: [],
|
||||
[Flag.AGENT_ACTIVITY]: true,
|
||||
[Flag.NEW_BLOCK_MENU]: false,
|
||||
[Flag.NEW_AGENT_RUNS]: false,
|
||||
[Flag.GRAPH_SEARCH]: true,
|
||||
|
||||
Reference in New Issue
Block a user