Merge branch 'dev' into swiftyos/caching-pt2

This commit is contained in:
Swifty
2025-10-10 11:32:22 +02:00
committed by GitHub
66 changed files with 1844 additions and 668 deletions

View File

@@ -37,5 +37,5 @@ class Project(BaseModel):
name: str
description: str
priority: int
progress: int
content: str
progress: float
content: str | None

View File

@@ -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:

View File

@@ -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")

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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

View File

@@ -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):

View File

@@ -67,6 +67,12 @@ export default defineConfig({
useQuery: true,
},
},
"getV2Builder search": {
query: {
useInfinite: true,
useInfiniteQueryParam: "page",
},
},
},
},
},

View File

@@ -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

View File

@@ -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";

View File

@@ -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[];

View File

@@ -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"))

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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,
};
});

View File

@@ -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,
};

View File

@@ -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>
</>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
)}
</>
);
};

View File

@@ -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,
};
};

View File

@@ -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>
</>
);
}

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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 && (

View File

@@ -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}

View File

@@ -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>
);

View File

@@ -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 />

View File

@@ -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">

View File

@@ -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>
);
};

View File

@@ -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");
};

View File

@@ -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,
};
};

View File

@@ -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(() => {

View File

@@ -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">

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 />;

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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",
}

View File

@@ -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;
};

View File

@@ -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",
}

View File

@@ -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,
}),
}));

View File

@@ -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`.
},
);
}

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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("/");
},
);
}

View File

@@ -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 {

View 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 },
);
}
}

View 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 },
);
}
}

View 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 },
);
}
}

View File

@@ -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;
}

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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 ? (

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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,