diff --git a/autogpt_platform/backend/backend/data/model.py b/autogpt_platform/backend/backend/data/model.py index 1c32a9f4444b..da89dcd2fecc 100644 --- a/autogpt_platform/backend/backend/data/model.py +++ b/autogpt_platform/backend/backend/data/model.py @@ -22,7 +22,7 @@ from urllib.parse import urlparse from uuid import uuid4 -from prisma.enums import CreditTransactionType +from prisma.enums import CreditTransactionType, OnboardingStep from pydantic import ( BaseModel, ConfigDict, @@ -855,3 +855,20 @@ class UserExecutionSummaryStats(BaseModel): total_execution_time: float = Field(default=0) average_execution_time: float = Field(default=0) cost_breakdown: dict[str, float] = Field(default_factory=dict) + + +class UserOnboarding(BaseModel): + userId: str + completedSteps: list[OnboardingStep] + walletShown: bool + notified: list[OnboardingStep] + rewardedFor: list[OnboardingStep] + usageReason: Optional[str] + integrations: list[str] + otherIntegrations: Optional[str] + selectedStoreListingVersionId: Optional[str] + agentInput: dict[str, Any] + onboardingAgentExecutionId: Optional[str] + agentRuns: int + lastRunAt: Optional[datetime] + consecutiveRunDays: int diff --git a/autogpt_platform/backend/backend/data/notification_bus.py b/autogpt_platform/backend/backend/data/notification_bus.py index ddd0681a2c13..6eb90dca1270 100644 --- a/autogpt_platform/backend/backend/data/notification_bus.py +++ b/autogpt_platform/backend/backend/data/notification_bus.py @@ -2,7 +2,7 @@ from typing import AsyncGenerator -from pydantic import BaseModel +from pydantic import BaseModel, field_serializer from backend.data.event_bus import AsyncRedisEventBus from backend.server.model import NotificationPayload @@ -15,6 +15,11 @@ class NotificationEvent(BaseModel): user_id: str payload: NotificationPayload + @field_serializer("payload") + def serialize_payload(self, payload: NotificationPayload): + """Ensure extra fields survive Redis serialization.""" + return payload.model_dump() + class AsyncRedisNotificationEventBus(AsyncRedisEventBus[NotificationEvent]): Model = NotificationEvent # type: ignore diff --git a/autogpt_platform/backend/backend/data/onboarding.py b/autogpt_platform/backend/backend/data/onboarding.py index 7cf8fbcb4f40..63c1295b6daf 100644 --- a/autogpt_platform/backend/backend/data/onboarding.py +++ b/autogpt_platform/backend/backend/data/onboarding.py @@ -1,6 +1,7 @@ import re -from datetime import datetime -from typing import Any, Optional +from datetime import datetime, timedelta, timezone +from typing import Any, Literal, Optional +from zoneinfo import ZoneInfo import prisma import pydantic @@ -8,6 +9,7 @@ from prisma.models import UserOnboarding from prisma.types import UserOnboardingCreateInput, UserOnboardingUpdateInput +from backend.data import execution as execution_db from backend.data.block import get_blocks from backend.data.credit import get_user_credit_model from backend.data.model import CredentialsMetaInput @@ -15,10 +17,12 @@ AsyncRedisNotificationEventBus, NotificationEvent, ) +from backend.data.user import get_user_by_id from backend.server.model import OnboardingNotificationPayload from backend.server.v2.store.model import StoreAgentDetails from backend.util.cache import cached from backend.util.json import SafeJson +from backend.util.timezone_utils import get_user_timezone_or_utc # Mapping from user reason id to categories to search for when choosing agent to show REASON_MAPPING: dict[str, list[str]] = { @@ -31,9 +35,20 @@ POINTS_AGENT_COUNT = 50 # Number of agents to calculate points for MIN_AGENT_COUNT = 2 # Minimum number of marketplace agents to enable onboarding +FrontendOnboardingStep = Literal[ + OnboardingStep.WELCOME, + OnboardingStep.USAGE_REASON, + OnboardingStep.INTEGRATIONS, + OnboardingStep.AGENT_CHOICE, + OnboardingStep.AGENT_NEW_RUN, + OnboardingStep.AGENT_INPUT, + OnboardingStep.CONGRATS, + OnboardingStep.MARKETPLACE_VISIT, + OnboardingStep.BUILDER_OPEN, +] + class UserOnboardingUpdate(pydantic.BaseModel): - completedSteps: Optional[list[OnboardingStep]] = None walletShown: Optional[bool] = None notified: Optional[list[OnboardingStep]] = None usageReason: Optional[str] = None @@ -42,9 +57,6 @@ class UserOnboardingUpdate(pydantic.BaseModel): selectedStoreListingVersionId: Optional[str] = None agentInput: Optional[dict[str, Any]] = None onboardingAgentExecutionId: Optional[str] = None - agentRuns: Optional[int] = None - lastRunAt: Optional[datetime] = None - consecutiveRunDays: Optional[int] = None async def get_user_onboarding(user_id: str): @@ -83,14 +95,6 @@ async def reset_user_onboarding(user_id: str): async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate): update: UserOnboardingUpdateInput = {} onboarding = await get_user_onboarding(user_id) - if data.completedSteps is not None: - update["completedSteps"] = list( - set(data.completedSteps + onboarding.completedSteps) - ) - for step in data.completedSteps: - if step not in onboarding.completedSteps: - await _reward_user(user_id, onboarding, step) - await _send_onboarding_notification(user_id, step) if data.walletShown: update["walletShown"] = data.walletShown if data.notified is not None: @@ -107,12 +111,6 @@ async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate): update["agentInput"] = SafeJson(data.agentInput) if data.onboardingAgentExecutionId is not None: update["onboardingAgentExecutionId"] = data.onboardingAgentExecutionId - if data.agentRuns is not None and data.agentRuns > onboarding.agentRuns: - update["agentRuns"] = data.agentRuns - if data.lastRunAt is not None: - update["lastRunAt"] = data.lastRunAt - if data.consecutiveRunDays is not None: - update["consecutiveRunDays"] = data.consecutiveRunDays return await UserOnboarding.prisma().upsert( where={"userId": user_id}, @@ -161,14 +159,12 @@ async def _reward_user(user_id: str, onboarding: UserOnboarding, step: Onboardin if step in onboarding.rewardedFor: return - onboarding.rewardedFor.append(step) user_credit_model = await get_user_credit_model(user_id) await user_credit_model.onboarding_reward(user_id, reward, step) await UserOnboarding.prisma().update( where={"userId": user_id}, data={ - "completedSteps": list(set(onboarding.completedSteps + [step])), - "rewardedFor": onboarding.rewardedFor, + "rewardedFor": list(set(onboarding.rewardedFor + [step])), }, ) @@ -177,31 +173,52 @@ async def complete_onboarding_step(user_id: str, step: OnboardingStep): """ Completes the specified onboarding step for the user if not already completed. """ - onboarding = await get_user_onboarding(user_id) if step not in onboarding.completedSteps: - await update_user_onboarding( - user_id, - UserOnboardingUpdate(completedSteps=onboarding.completedSteps + [step]), + await UserOnboarding.prisma().update( + where={"userId": user_id}, + data={ + "completedSteps": list(set(onboarding.completedSteps + [step])), + }, ) + await _reward_user(user_id, onboarding, step) await _send_onboarding_notification(user_id, step) -async def _send_onboarding_notification(user_id: str, step: OnboardingStep): +async def _send_onboarding_notification( + user_id: str, step: OnboardingStep | None, event: str = "step_completed" +): """ - Sends an onboarding notification to the user for the specified step. + Sends an onboarding notification to the user. """ payload = OnboardingNotificationPayload( type="onboarding", - event="step_completed", - step=step.value, + event=event, + step=step, ) await AsyncRedisNotificationEventBus().publish( NotificationEvent(user_id=user_id, payload=payload) ) -def clean_and_split(text: str) -> list[str]: +async def complete_re_run_agent(user_id: str, graph_id: str) -> None: + """ + Complete RE_RUN_AGENT step when a user runs a graph they've run before. + Keeps overhead low by only counting executions if the step is still pending. + """ + onboarding = await get_user_onboarding(user_id) + if OnboardingStep.RE_RUN_AGENT in onboarding.completedSteps: + return + + # Includes current execution, so count > 1 means there was at least one prior run. + previous_exec_count = await execution_db.get_graph_executions_count( + user_id=user_id, graph_id=graph_id + ) + if previous_exec_count > 1: + await complete_onboarding_step(user_id, OnboardingStep.RE_RUN_AGENT) + + +def _clean_and_split(text: str) -> list[str]: """ Removes all special characters from a string, truncates it to 100 characters, and splits it by whitespace and commas. @@ -224,7 +241,7 @@ def clean_and_split(text: str) -> list[str]: return words -def calculate_points( +def _calculate_points( agent, categories: list[str], custom: list[str], integrations: list[str] ) -> int: """ @@ -282,13 +299,94 @@ def get_credentials_blocks() -> dict[str, str]: CREDENTIALS_FIELDS: dict[str, str] = get_credentials_blocks() +def _normalize_datetime(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _calculate_consecutive_run_days( + last_run_at: datetime | None, current_consecutive_days: int, user_timezone: str +) -> tuple[datetime, int]: + tz = ZoneInfo(user_timezone) + local_now = datetime.now(tz) + normalized_last_run = _normalize_datetime(last_run_at) + + if normalized_last_run is None: + return local_now.astimezone(timezone.utc), 1 + + last_run_local = normalized_last_run.astimezone(tz) + last_run_date = last_run_local.date() + today = local_now.date() + + if last_run_date == today: + return local_now.astimezone(timezone.utc), current_consecutive_days + + if last_run_date == today - timedelta(days=1): + return local_now.astimezone(timezone.utc), current_consecutive_days + 1 + + return local_now.astimezone(timezone.utc), 1 + + +def _get_run_milestone_steps( + new_run_count: int, consecutive_days: int +) -> list[OnboardingStep]: + milestones: list[OnboardingStep] = [] + if new_run_count >= 10: + milestones.append(OnboardingStep.RUN_AGENTS) + if new_run_count >= 100: + milestones.append(OnboardingStep.RUN_AGENTS_100) + if consecutive_days >= 3: + milestones.append(OnboardingStep.RUN_3_DAYS) + if consecutive_days >= 14: + milestones.append(OnboardingStep.RUN_14_DAYS) + return milestones + + +async def _get_user_timezone(user_id: str) -> str: + user = await get_user_by_id(user_id) + return get_user_timezone_or_utc(user.timezone if user else None) + + +async def increment_runs(user_id: str): + """ + Increment a user's run counters and trigger any onboarding milestones. + """ + user_timezone = await _get_user_timezone(user_id) + onboarding = await get_user_onboarding(user_id) + new_run_count = onboarding.agentRuns + 1 + last_run_at, consecutive_run_days = _calculate_consecutive_run_days( + onboarding.lastRunAt, onboarding.consecutiveRunDays, user_timezone + ) + + await UserOnboarding.prisma().update( + where={"userId": user_id}, + data={ + "agentRuns": {"increment": 1}, + "lastRunAt": last_run_at, + "consecutiveRunDays": consecutive_run_days, + }, + ) + + milestones = _get_run_milestone_steps(new_run_count, consecutive_run_days) + new_steps = [step for step in milestones if step not in onboarding.completedSteps] + + for step in new_steps: + await complete_onboarding_step(user_id, step) + # Send progress notification if no steps were completed, so client refetches onboarding state + if not new_steps: + await _send_onboarding_notification(user_id, None, event="increment_runs") + + async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]: user_onboarding = await get_user_onboarding(user_id) categories = REASON_MAPPING.get(user_onboarding.usageReason or "", []) where_clause: dict[str, Any] = {} - custom = clean_and_split((user_onboarding.usageReason or "").lower()) + custom = _clean_and_split((user_onboarding.usageReason or "").lower()) if categories: where_clause["OR"] = [ @@ -336,7 +434,7 @@ async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]: # Calculate points for the first X agents and choose the top 2 agent_points = [] for agent in storeAgents[:POINTS_AGENT_COUNT]: - points = calculate_points( + points = _calculate_points( agent, categories, custom, user_onboarding.integrations ) agent_points.append((agent, points)) diff --git a/autogpt_platform/backend/backend/executor/scheduler.py b/autogpt_platform/backend/backend/executor/scheduler.py index cfa31af64e24..0b7fbc683bec 100644 --- a/autogpt_platform/backend/backend/executor/scheduler.py +++ b/autogpt_platform/backend/backend/executor/scheduler.py @@ -26,6 +26,7 @@ from backend.data.block import BlockInput from backend.data.execution import GraphExecutionWithNodes from backend.data.model import CredentialsMetaInput +from backend.data.onboarding import increment_runs from backend.executor import utils as execution_utils from backend.monitoring import ( NotificationJobArgs, @@ -153,6 +154,7 @@ async def _execute_graph(**kwargs): inputs=args.input_data, graph_credentials_inputs=args.input_credentials, ) + await increment_runs(args.user_id) elapsed = asyncio.get_event_loop().time() - start_time logger.info( f"Graph execution started with ID {graph_exec.id} for graph {args.graph_id} " diff --git a/autogpt_platform/backend/backend/server/integrations/router.py b/autogpt_platform/backend/backend/server/integrations/router.py index 36523e867a92..b4227ad02acf 100644 --- a/autogpt_platform/backend/backend/server/integrations/router.py +++ b/autogpt_platform/backend/backend/server/integrations/router.py @@ -33,7 +33,11 @@ OAuth2Credentials, UserIntegrations, ) -from backend.data.onboarding import OnboardingStep, complete_onboarding_step +from backend.data.onboarding import ( + OnboardingStep, + complete_onboarding_step, + increment_runs, +) from backend.data.user import get_user_integrations from backend.executor.utils import add_graph_execution from backend.integrations.ayrshare import AyrshareClient, SocialPlatform @@ -377,6 +381,7 @@ async def webhook_ingress_generic( return await complete_onboarding_step(user_id, OnboardingStep.TRIGGER_WEBHOOK) + await increment_runs(user_id) # Execute all triggers concurrently for better performance tasks = [] diff --git a/autogpt_platform/backend/backend/server/model.py b/autogpt_platform/backend/backend/server/model.py index 24ba4fa7eeff..1d7b79cd7c8b 100644 --- a/autogpt_platform/backend/backend/server/model.py +++ b/autogpt_platform/backend/backend/server/model.py @@ -1,7 +1,8 @@ import enum -from typing import Any, Optional +from typing import Any, Literal, Optional import pydantic +from prisma.enums import OnboardingStep from backend.data.api_key import APIKeyInfo, APIKeyPermission from backend.data.graph import Graph @@ -35,8 +36,13 @@ class WSSubscribeGraphExecutionsRequest(pydantic.BaseModel): graph_id: str +GraphCreationSource = Literal["builder", "upload"] +GraphExecutionSource = Literal["builder", "library", "onboarding"] + + class CreateGraph(pydantic.BaseModel): graph: Graph + source: GraphCreationSource | None = None class CreateAPIKeyRequest(pydantic.BaseModel): @@ -83,6 +89,8 @@ class NotificationPayload(pydantic.BaseModel): type: str event: str + model_config = pydantic.ConfigDict(extra="allow") + class OnboardingNotificationPayload(NotificationPayload): - step: str + step: OnboardingStep | None diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index e38dd77ac597..9d5a12b8a625 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -5,7 +5,7 @@ import uuid from collections import defaultdict from datetime import datetime, timezone -from typing import Annotated, Any, Sequence +from typing import Annotated, Any, Sequence, get_args import pydantic import stripe @@ -45,12 +45,17 @@ set_auto_top_up, ) from backend.data.execution import UserContext -from backend.data.model import CredentialsMetaInput +from backend.data.model import CredentialsMetaInput, UserOnboarding from backend.data.notifications import NotificationPreference, NotificationPreferenceDTO from backend.data.onboarding import ( + FrontendOnboardingStep, + OnboardingStep, UserOnboardingUpdate, + complete_onboarding_step, + complete_re_run_agent, get_recommended_agents, get_user_onboarding, + increment_runs, onboarding_enabled, reset_user_onboarding, update_user_onboarding, @@ -78,6 +83,7 @@ CreateAPIKeyRequest, CreateAPIKeyResponse, CreateGraph, + GraphExecutionSource, RequestTopUp, SetGraphActiveVersion, TimezoneResponse, @@ -85,6 +91,7 @@ UpdateTimezoneRequest, UploadFileResponse, ) +from backend.server.v2.store.model import StoreAgentDetails from backend.util.cache import cached from backend.util.clients import get_scheduler_client from backend.util.cloud_storage import get_cloud_storage_handler @@ -252,9 +259,10 @@ async def update_preferences( @v1_router.get( "/onboarding", - summary="Get onboarding status", + summary="Onboarding state", tags=["onboarding"], dependencies=[Security(requires_user)], + response_model=UserOnboarding, ) async def get_onboarding(user_id: Annotated[str, Security(get_user_id)]): return await get_user_onboarding(user_id) @@ -262,9 +270,10 @@ async def get_onboarding(user_id: Annotated[str, Security(get_user_id)]): @v1_router.patch( "/onboarding", - summary="Update onboarding progress", + summary="Update onboarding state", tags=["onboarding"], dependencies=[Security(requires_user)], + response_model=UserOnboarding, ) async def update_onboarding( user_id: Annotated[str, Security(get_user_id)], data: UserOnboardingUpdate @@ -272,25 +281,39 @@ async def update_onboarding( return await update_user_onboarding(user_id, data) +@v1_router.post( + "/onboarding/step", + summary="Complete onboarding step", + tags=["onboarding"], + dependencies=[Security(requires_user)], +) +async def onboarding_complete_step( + user_id: Annotated[str, Security(get_user_id)], step: FrontendOnboardingStep +): + if step not in get_args(FrontendOnboardingStep): + raise HTTPException(status_code=400, detail="Invalid onboarding step") + return await complete_onboarding_step(user_id, step) + + @v1_router.get( "/onboarding/agents", - summary="Get recommended agents", + summary="Recommended onboarding agents", tags=["onboarding"], dependencies=[Security(requires_user)], ) async def get_onboarding_agents( user_id: Annotated[str, Security(get_user_id)], -): +) -> list[StoreAgentDetails]: return await get_recommended_agents(user_id) @v1_router.get( "/onboarding/enabled", - summary="Check onboarding enabled", + summary="Is onboarding enabled", tags=["onboarding", "public"], dependencies=[Security(requires_user)], ) -async def is_onboarding_enabled(): +async def is_onboarding_enabled() -> bool: return await onboarding_enabled() @@ -299,6 +322,7 @@ async def is_onboarding_enabled(): summary="Reset onboarding progress", tags=["onboarding"], dependencies=[Security(requires_user)], + response_model=UserOnboarding, ) async def reset_onboarding(user_id: Annotated[str, Security(get_user_id)]): return await reset_user_onboarding(user_id) @@ -791,7 +815,12 @@ async def create_new_graph( # as the graph already valid and no sub-graphs are returned back. await graph_db.create_graph(graph, user_id=user_id) await library_db.create_library_agent(graph, user_id=user_id) - return await on_graph_activate(graph, user_id=user_id) + activated_graph = await on_graph_activate(graph, user_id=user_id) + + if create_graph.source == "builder": + await complete_onboarding_step(user_id, OnboardingStep.BUILDER_SAVE_AGENT) + + return activated_graph @v1_router.delete( @@ -923,6 +952,7 @@ async def execute_graph( credentials_inputs: Annotated[ dict[str, CredentialsMetaInput], Body(..., embed=True, default_factory=dict) ], + source: Annotated[GraphExecutionSource | None, Body(embed=True)] = None, graph_version: Optional[int] = None, preset_id: Optional[str] = None, ) -> execution_db.GraphExecutionMeta: @@ -946,6 +976,14 @@ async def execute_graph( # Record successful graph execution record_graph_execution(graph_id=graph_id, status="success", user_id=user_id) record_graph_operation(operation="execute", status="success") + await increment_runs(user_id) + await complete_re_run_agent(user_id, graph_id) + if source == "library": + await complete_onboarding_step( + user_id, OnboardingStep.MARKETPLACE_RUN_AGENT + ) + elif source == "builder": + await complete_onboarding_step(user_id, OnboardingStep.BUILDER_RUN_AGENT) return result except GraphValidationError as e: # Record failed graph execution @@ -1059,6 +1097,15 @@ async def list_graph_executions( filtered_executions = await hide_activity_summaries_if_disabled( paginated_result.executions, user_id ) + onboarding = await get_user_onboarding(user_id) + if ( + onboarding.onboardingAgentExecutionId + and onboarding.onboardingAgentExecutionId + in [exec.id for exec in filtered_executions] + and OnboardingStep.GET_RESULTS not in onboarding.completedSteps + ): + await complete_onboarding_step(user_id, OnboardingStep.GET_RESULTS) + return execution_db.GraphExecutionsPaginated( executions=filtered_executions, pagination=paginated_result.pagination ) @@ -1096,6 +1143,12 @@ async def get_graph_execution( # Apply feature flags to filter out disabled features result = await hide_activity_summary_if_disabled(result, user_id) + onboarding = await get_user_onboarding(user_id) + if ( + onboarding.onboardingAgentExecutionId == graph_exec_id + and OnboardingStep.GET_RESULTS not in onboarding.completedSteps + ): + await complete_onboarding_step(user_id, OnboardingStep.GET_RESULTS) return result @@ -1272,6 +1325,8 @@ async def create_graph_execution_schedule( result.next_run_time, user_timezone ) + await complete_onboarding_step(user_id, OnboardingStep.SCHEDULE_AGENT) + return result diff --git a/autogpt_platform/backend/backend/server/v2/library/routes/agents.py b/autogpt_platform/backend/backend/server/v2/library/routes/agents.py index 1bdf255ce50e..4681cf17c4b7 100644 --- a/autogpt_platform/backend/backend/server/v2/library/routes/agents.py +++ b/autogpt_platform/backend/backend/server/v2/library/routes/agents.py @@ -1,13 +1,15 @@ import logging -from typing import Optional +from typing import Literal, Optional import autogpt_libs.auth as autogpt_auth_lib from fastapi import APIRouter, Body, HTTPException, Query, Security, status from fastapi.responses import Response +from prisma.enums import OnboardingStep import backend.server.v2.library.db as library_db import backend.server.v2.library.model as library_model import backend.server.v2.store.exceptions as store_exceptions +from backend.data.onboarding import complete_onboarding_step from backend.util.exceptions import DatabaseError, NotFoundError logger = logging.getLogger(__name__) @@ -193,6 +195,9 @@ async def get_library_agent_by_store_listing_version_id( ) async def add_marketplace_agent_to_library( store_listing_version_id: str = Body(embed=True), + source: Literal["onboarding", "marketplace"] = Body( + default="marketplace", embed=True + ), user_id: str = Security(autogpt_auth_lib.get_user_id), ) -> library_model.LibraryAgent: """ @@ -210,10 +215,15 @@ async def add_marketplace_agent_to_library( HTTPException(500): If a server/database error occurs. """ try: - return await library_db.add_store_agent_to_library( + agent = await library_db.add_store_agent_to_library( store_listing_version_id=store_listing_version_id, user_id=user_id, ) + if source != "onboarding": + await complete_onboarding_step( + user_id, OnboardingStep.MARKETPLACE_ADD_AGENT + ) + return agent except store_exceptions.AgentNotFoundError as e: logger.warning( diff --git a/autogpt_platform/backend/backend/server/v2/library/routes/presets.py b/autogpt_platform/backend/backend/server/v2/library/routes/presets.py index 12bc77629a7a..b1810395f05d 100644 --- a/autogpt_platform/backend/backend/server/v2/library/routes/presets.py +++ b/autogpt_platform/backend/backend/server/v2/library/routes/presets.py @@ -10,6 +10,7 @@ from backend.data.graph import get_graph from backend.data.integrations import get_webhook from backend.data.model import CredentialsMetaInput +from backend.data.onboarding import increment_runs from backend.executor.utils import add_graph_execution, make_node_credentials_input_map from backend.integrations.creds_manager import IntegrationCredentialsManager from backend.integrations.webhooks import get_webhook_manager @@ -401,6 +402,8 @@ async def execute_preset( merged_node_input = preset.inputs | inputs merged_credential_inputs = preset.credentials | credential_inputs + await increment_runs(user_id) + return await add_graph_execution( user_id=user_id, graph_id=preset.graph_id, diff --git a/autogpt_platform/backend/backend/server/v2/library/routes_test.py b/autogpt_platform/backend/backend/server/v2/library/routes_test.py index 85f66c3df207..e2cd0252d39e 100644 --- a/autogpt_platform/backend/backend/server/v2/library/routes_test.py +++ b/autogpt_platform/backend/backend/server/v2/library/routes_test.py @@ -1,5 +1,6 @@ import datetime import json +from unittest.mock import AsyncMock import fastapi.testclient import pytest @@ -221,6 +222,10 @@ def test_add_agent_to_library_success( "backend.server.v2.library.db.add_store_agent_to_library" ) mock_db_call.return_value = mock_library_agent + mock_complete_onboarding = mocker.patch( + "backend.server.v2.library.routes.agents.complete_onboarding_step", + new_callable=AsyncMock, + ) response = client.post( "/agents", json={"store_listing_version_id": "test-version-id"} @@ -235,6 +240,7 @@ def test_add_agent_to_library_success( mock_db_call.assert_called_once_with( store_listing_version_id="test-version-id", user_id=test_user_id ) + mock_complete_onboarding.assert_awaited_once() def test_add_agent_to_library_error(mocker: pytest_mock.MockFixture, test_user_id: str): diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/4-agent/page.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/4-agent/page.tsx index 8b77f6f7c29f..e75b2fc28e3f 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/4-agent/page.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/4-agent/page.tsx @@ -1,6 +1,4 @@ "use client"; -import { StoreAgentDetails } from "@/lib/autogpt-server-api"; -import { useBackendAPI } from "@/lib/autogpt-server-api/context"; import { isEmptyOrWhitespace } from "@/lib/utils"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -13,15 +11,17 @@ import { OnboardingStep, } from "../components/OnboardingStep"; import { OnboardingText } from "../components/OnboardingText"; +import { getV1RecommendedOnboardingAgents } from "@/app/api/__generated__/endpoints/onboarding/onboarding"; +import { resolveResponse } from "@/app/api/helpers"; +import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails"; export default function Page() { const { state, updateState, completeStep } = useOnboarding(4, "INTEGRATIONS"); const [agents, setAgents] = useState([]); - const api = useBackendAPI(); const router = useRouter(); useEffect(() => { - api.getOnboardingAgents().then((agents) => { + resolveResponse(getV1RecommendedOnboardingAgents()).then((agents) => { if (agents.length < 2) { completeStep("CONGRATS"); router.replace("/"); diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/5-run/useOnboardingRunStep.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/5-run/useOnboardingRunStep.tsx index ce34036702e6..37538a21913a 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/5-run/useOnboardingRunStep.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/5-run/useOnboardingRunStep.tsx @@ -12,6 +12,9 @@ import { useGetV2GetAgentByVersion, useGetV2GetAgentGraph, } from "@/app/api/__generated__/endpoints/store/store"; +import { resolveResponse } from "@/app/api/helpers"; +import { postV2AddMarketplaceAgent } from "@/app/api/__generated__/endpoints/library/library"; +import { GraphID } from "@/lib/autogpt-server-api"; export function useOnboardingRunStep() { const onboarding = useOnboarding(undefined, "AGENT_CHOICE"); @@ -77,12 +80,7 @@ export function useOnboardingRunStep() { setShowInput(true); onboarding.setStep(6); - onboarding.updateState({ - completedSteps: [ - ...(onboarding.state.completedSteps || []), - "AGENT_NEW_RUN", - ], - }); + onboarding.completeStep("AGENT_NEW_RUN"); } function handleSetAgentInput(key: string, value: string) { @@ -111,21 +109,22 @@ export function useOnboardingRunStep() { setRunningAgent(true); try { - const libraryAgent = await api.addMarketplaceAgentToLibrary( - storeAgent?.store_listing_version_id || "", + const libraryAgent = await resolveResponse( + postV2AddMarketplaceAgent({ + store_listing_version_id: storeAgent?.store_listing_version_id || "", + source: "onboarding", + }), ); const { id: runID } = await api.executeGraph( - libraryAgent.graph_id, + libraryAgent.graph_id as GraphID, libraryAgent.graph_version, onboarding.state.agentInput || {}, inputCredentials, + "onboarding", ); - onboarding.updateState({ - onboardingAgentExecutionId: runID, - agentRuns: (onboarding.state.agentRuns || 0) + 1, - }); + onboarding.updateState({ onboardingAgentExecutionId: runID }); router.push("/onboarding/6-congrats"); } catch (error) { diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/6-congrats/page.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/6-congrats/page.tsx index 6fcc40a53f85..b3b4e4f4586f 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/6-congrats/page.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/6-congrats/page.tsx @@ -5,6 +5,9 @@ import { useRouter } from "next/navigation"; import * as party from "party-js"; import { useEffect, useRef, useState } from "react"; import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider"; +import { resolveResponse } from "@/app/api/helpers"; +import { getV1OnboardingState } from "@/app/api/__generated__/endpoints/onboarding/onboarding"; +import { postV2AddMarketplaceAgent } from "@/app/api/__generated__/endpoints/library/library"; export default function Page() { const { completeStep } = useOnboarding(7, "AGENT_INPUT"); @@ -37,11 +40,15 @@ export default function Page() { completeStep("CONGRATS"); try { - const onboarding = await api.getUserOnboarding(); + const onboarding = await resolveResponse(getV1OnboardingState()); if (onboarding?.selectedStoreListingVersionId) { try { - const libraryAgent = await api.addMarketplaceAgentToLibrary( - onboarding.selectedStoreListingVersionId, + const libraryAgent = await resolveResponse( + postV2AddMarketplaceAgent({ + store_listing_version_id: + onboarding.selectedStoreListingVersionId, + source: "onboarding", + }), ); router.replace(`/library/agents/${libraryAgent.id}`); } catch (error) { diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/OnboardingAgentCard.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/OnboardingAgentCard.tsx index 19d26d7cb3df..841b0bb50af0 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/OnboardingAgentCard.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/components/OnboardingAgentCard.tsx @@ -1,7 +1,7 @@ import { cn } from "@/lib/utils"; import StarRating from "./StarRating"; -import { StoreAgentDetails } from "@/lib/autogpt-server-api"; import SmartImage from "@/components/__legacy__/SmartImage"; +import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails"; type OnboardingAgentCardProps = { agent?: StoreAgentDetails; diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/page.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/page.tsx index f404c62d7592..1ebfe6b87bb4 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/page.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/page.tsx @@ -1,24 +1,24 @@ "use client"; import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; -import { useBackendAPI } from "@/lib/autogpt-server-api/context"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; +import { resolveResponse, shouldShowOnboarding } from "@/app/api/helpers"; +import { getV1OnboardingState } from "@/app/api/__generated__/endpoints/onboarding/onboarding"; export default function OnboardingPage() { const router = useRouter(); - const api = useBackendAPI(); useEffect(() => { async function redirectToStep() { try { // Check if onboarding is enabled - const isEnabled = await api.isOnboardingEnabled(); + const isEnabled = await shouldShowOnboarding(); if (!isEnabled) { router.replace("/"); return; } - const onboarding = await api.getUserOnboarding(); + const onboarding = await resolveResponse(getV1OnboardingState()); // Handle completed onboarding if (onboarding.completedSteps.includes("GET_RESULTS")) { @@ -66,7 +66,7 @@ export default function OnboardingPage() { } redirectToStep(); - }, [api, router]); + }, [router]); return ; } diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts index beb3a8741ad7..7781d3cd68ca 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts @@ -76,7 +76,7 @@ export const useRunGraph = () => { await executeGraph({ graphId: flowID ?? "", graphVersion: flowVersion || null, - data: { inputs: {}, credentials_inputs: {} }, + data: { inputs: {}, credentials_inputs: {}, source: "builder" }, }); } }; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts index 054aa36ebddd..93154e522966 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts @@ -82,7 +82,11 @@ export const useRunInputDialog = ({ await executeGraph({ graphId: flowID ?? "", graphVersion: flowVersion || null, - data: { inputs: inputValues, credentials_inputs: credentialValues }, + data: { + inputs: inputValues, + credentials_inputs: credentialValues, + source: "builder", + }, }); setIsOpen(false); }; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/RunnerInputUI.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/RunnerInputUI.tsx index 21d93f6fe02c..bff21c46f276 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/RunnerInputUI.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/RunnerInputUI.tsx @@ -83,7 +83,6 @@ export function RunnerInputDialog({ onRun={doRun ? undefined : doClose} doCreateSchedule={doCreateSchedule ? handleSchedule : undefined} onCreateSchedule={doCreateSchedule ? undefined : doClose} - runCount={0} /> diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts index fe2a5e0b230a..d0b488f26c03 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts @@ -152,7 +152,9 @@ export const useSaveGraph = ({ links: graphLinks, }; - const response = await createNewGraph({ data: { graph: data } }); + const response = await createNewGraph({ + data: { graph: data, source: "builder" }, + }); const graphData = response.data as GraphModel; setGraphSchemas( graphData.input_schema, diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentModal/useAgentRunModal.ts b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentModal/useAgentRunModal.ts index 2e8fc02d9778..f801e1d958b8 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentModal/useAgentRunModal.ts +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentModal/useAgentRunModal.ts @@ -16,7 +16,6 @@ import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutio import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo"; import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset"; import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth"; -import { useOnboarding } from "@/providers/onboarding/onboarding-provider"; import { analytics } from "@/services/analytics"; export type RunVariant = @@ -50,7 +49,6 @@ export function useAgentRunModal( const [cronExpression, setCronExpression] = useState( agent.recommended_schedule_cron || "0 9 * * 1", ); - const { completeStep: completeOnboardingStep } = useOnboarding(); // Get user timezone for scheduling const { data: userTimezone } = useGetV1GetUserTimezone({ @@ -286,6 +284,7 @@ export function useAgentRunModal( data: { inputs: inputValues, credentials_inputs: inputCredentials, + source: "library", }, }); } @@ -331,8 +330,6 @@ export function useAgentRunModal( userTimezone && userTimezone !== "not-set" ? userTimezone : undefined, }, }); - - completeOnboardingStep("SCHEDULE_AGENT"); }, [ allRequiredInputsAreSet, scheduleName, diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunDetailHeader/useRunDetailHeader.ts b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunDetailHeader/useRunDetailHeader.ts index f90c495df64c..6aa69b5d2580 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunDetailHeader/useRunDetailHeader.ts +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunDetailHeader/useRunDetailHeader.ts @@ -105,6 +105,7 @@ export function useRunDetailHeader( data: { inputs: (run as any).inputs || {}, credentials_inputs: (run as any).credential_inputs || {}, + source: "library", }, }); diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/OldAgentLibraryView.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/OldAgentLibraryView.tsx index 28a3585df8ca..dff1b5a7bb80 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/OldAgentLibraryView.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/OldAgentLibraryView.tsx @@ -47,7 +47,6 @@ import { CreatePresetDialog } from "./components/create-preset-dialog"; import { useAgentRunsInfinite } from "./use-agent-runs"; import { AgentRunsSelectorList } from "./components/agent-runs-selector-list"; import { AgentScheduleDetailsView } from "./components/agent-schedule-details-view"; -import { useOnboarding } from "@/providers/onboarding/onboarding-provider"; export function OldAgentLibraryView() { const { id: agentID }: { id: LibraryAgentID } = useParams(); @@ -84,11 +83,6 @@ export function OldAgentLibraryView() { useState(null); const [confirmingDeleteAgentPreset, setConfirmingDeleteAgentPreset] = useState(null); - const { - state: onboardingState, - updateState: updateOnboardingState, - incrementRuns, - } = useOnboarding(); const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false); const [creatingPresetFromExecutionID, setCreatingPresetFromExecutionID] = useState(null); @@ -136,22 +130,6 @@ export function OldAgentLibraryView() { [api, graphVersions, loadingGraphVersions], ); - // Reward user for viewing results of their onboarding agent - useEffect(() => { - if ( - !onboardingState || - !selectedRun || - onboardingState.completedSteps.includes("GET_RESULTS") - ) - return; - - if (selectedRun.id === onboardingState.onboardingAgentExecutionId) { - updateOnboardingState({ - completedSteps: [...onboardingState.completedSteps, "GET_RESULTS"], - }); - } - }, [selectedRun, onboardingState, updateOnboardingState]); - const lastRefresh = useRef(0); const refreshPageData = useCallback(() => { if (Date.now() - lastRefresh.current < 2e3) return; // 2 second debounce @@ -285,10 +263,6 @@ export function OldAgentLibraryView() { (data) => { if (data.graph_id != agent?.graph_id) return; - if (data.status == "COMPLETED") { - incrementRuns(); - } - agentRunsQuery.upsertAgentRun(data); if (data.id === selectedView.id) { // Update currently viewed run @@ -300,7 +274,7 @@ export function OldAgentLibraryView() { return () => { detachExecUpdateHandler(); }; - }, [api, agent?.graph_id, selectedView.id, incrementRuns]); + }, [api, agent?.graph_id, selectedView.id]); // Pre-load selectedRun based on selectedView useEffect(() => { @@ -558,7 +532,6 @@ export function OldAgentLibraryView() { onCreateSchedule={onCreateSchedule} onCreatePreset={onCreatePreset} agentActions={agentActions} - runCount={agentRuns.length} recommendedScheduleCron={agent?.recommended_schedule_cron || null} /> ) : selectedView.type == "preset" ? ( @@ -574,7 +547,6 @@ export function OldAgentLibraryView() { onUpdatePreset={onUpdatePreset} doDeletePreset={setConfirmingDeleteAgentPreset} agentActions={agentActions} - runCount={agentRuns.length} /> ) : selectedView.type == "schedule" ? ( selectedSchedule && diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-details-view.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-details-view.tsx index b513ff39a776..cfc2a30923a6 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-details-view.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-details-view.tsx @@ -38,7 +38,6 @@ import { AgentRunStatus, agentRunStatusMap } from "./agent-run-status-chip"; import useCredits from "@/hooks/useCredits"; import { AgentRunOutputView } from "./agent-run-output-view"; import { analytics } from "@/services/analytics"; -import { useOnboarding } from "@/providers/onboarding/onboarding-provider"; export function AgentRunDetailsView({ agent, @@ -65,8 +64,6 @@ export function AgentRunDetailsView({ [run], ); - const { completeStep } = useOnboarding(); - const toastOnFail = useToastOnFail(); const infoStats: { label: string; value: React.ReactNode }[] = useMemo(() => { @@ -151,13 +148,13 @@ export function AgentRunDetailsView({ graph.version, run.inputs!, run.credential_inputs!, + "library", ) .then(({ id }) => { analytics.sendDatafastEvent("run_agent", { name: graph.name, id: graph.id, }); - completeStep("RE_RUN_AGENT"); onRun(id); }) .catch(toastOnFail("execute agent")); diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view.tsx index a037ca9645ea..13faca2f08e5 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view.tsx @@ -42,7 +42,6 @@ import { } from "@/components/molecules/Toast/use-toast"; import { AgentStatus, AgentStatusChip } from "./agent-status-chip"; -import { useOnboarding } from "@/providers/onboarding/onboarding-provider"; import { analytics } from "@/services/analytics"; export function AgentRunDraftView({ @@ -56,7 +55,6 @@ export function AgentRunDraftView({ doCreateSchedule: _doCreateSchedule, onCreateSchedule, agentActions, - runCount, className, recommendedScheduleCron, }: { @@ -75,7 +73,6 @@ export function AgentRunDraftView({ credentialsInputs: Record, ) => Promise; onCreateSchedule?: (schedule: Schedule) => void; - runCount: number; className?: string; } & ( | { @@ -104,7 +101,6 @@ export function AgentRunDraftView({ const [changedPresetAttributes, setChangedPresetAttributes] = useState< Set >(new Set()); - const { completeStep: completeOnboardingStep } = useOnboarding(); const [cronScheduleDialogOpen, setCronScheduleDialogOpen] = useState(false); // Update values if agentPreset parameter is changed @@ -194,7 +190,13 @@ export function AgentRunDraftView({ } // TODO: on executing preset with changes, ask for confirmation and offer save+run const newRun = await api - .executeGraph(graph.id, graph.version, inputValues, inputCredentials) + .executeGraph( + graph.id, + graph.version, + inputValues, + inputCredentials, + "library", + ) .catch(toastOnFail("execute agent")); if (newRun && onRun) onRun(newRun.id); @@ -204,26 +206,12 @@ export function AgentRunDraftView({ .then((newRun) => onRun && onRun(newRun.id)) .catch(toastOnFail("execute agent preset")); } - // Mark run agent onboarding step as completed - completeOnboardingStep("MARKETPLACE_RUN_AGENT"); analytics.sendDatafastEvent("run_agent", { name: graph.name, id: graph.id, }); - - if (runCount > 0) { - completeOnboardingStep("RE_RUN_AGENT"); - } - }, [ - api, - graph, - inputValues, - inputCredentials, - onRun, - toastOnFail, - completeOnboardingStep, - ]); + }, [api, graph, inputValues, inputCredentials, onRun, toastOnFail]); const doCreatePreset = useCallback(async () => { if (!onCreatePreset) return; @@ -257,7 +245,6 @@ export function AgentRunDraftView({ onCreatePreset, toast, toastOnFail, - completeOnboardingStep, ]); const doUpdatePreset = useCallback(async () => { @@ -296,7 +283,6 @@ export function AgentRunDraftView({ onUpdatePreset, toast, toastOnFail, - completeOnboardingStep, ]); const doSetPresetActive = useCallback( @@ -343,7 +329,6 @@ export function AgentRunDraftView({ onCreatePreset, toast, toastOnFail, - completeOnboardingStep, ]); const openScheduleDialog = useCallback(() => { diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-schedule-details-view.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-schedule-details-view.tsx index b90a4f24d6ca..414aa3863b66 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-schedule-details-view.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-schedule-details-view.tsx @@ -100,6 +100,7 @@ export function AgentScheduleDetailsView({ graph.version, schedule.input_data, schedule.input_credentials, + "library", ) .then((run) => onForcedRun(run.id)) .catch(toastOnFail("execute agent")), diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog.tsx index a587d11aff1d..e998823a89ad 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/cron-scheduler-dialog.tsx @@ -7,7 +7,6 @@ import { Dialog } from "@/components/molecules/Dialog/Dialog"; import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth"; import { getTimezoneDisplayName } from "@/lib/timezone-utils"; import { InfoIcon } from "lucide-react"; -import { useOnboarding } from "@/providers/onboarding/onboarding-provider"; // Base type for cron expression only type CronOnlyCallback = (cronExpression: string) => void; @@ -49,7 +48,6 @@ export function CronSchedulerDialog(props: CronSchedulerDialogProps) { const [scheduleName, setScheduleName] = useState( props.mode === "with-name" ? props.defaultScheduleName || "" : "", ); - const { completeStep } = useOnboarding(); // Get user's timezone const { data: userTimezone } = useGetV1GetUserTimezone({ @@ -94,7 +92,6 @@ export function CronSchedulerDialog(props: CronSchedulerDialogProps) { props.onSubmit(cronExpression); } setOpen(false); - completeStep("SCHEDULE_AGENT"); }; return ( diff --git a/autogpt_platform/frontend/src/app/(platform)/library/components/LibraryUploadAgentDialog/useLibraryUploadAgentDialog.ts b/autogpt_platform/frontend/src/app/(platform)/library/components/LibraryUploadAgentDialog/useLibraryUploadAgentDialog.ts index 4bbd0eb2e6cf..689753a3402a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/components/LibraryUploadAgentDialog/useLibraryUploadAgentDialog.ts +++ b/autogpt_platform/frontend/src/app/(platform)/library/components/LibraryUploadAgentDialog/useLibraryUploadAgentDialog.ts @@ -62,6 +62,7 @@ export const useLibraryUploadAgentDialog = () => { await createGraph({ data: { graph: payload, + source: "upload", }, }); }; diff --git a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/AgentInfo/useAgentInfo.ts b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/AgentInfo/useAgentInfo.ts index b9558c09f448..37d8a5d75acd 100644 --- a/autogpt_platform/frontend/src/app/(platform)/marketplace/components/AgentInfo/useAgentInfo.ts +++ b/autogpt_platform/frontend/src/app/(platform)/marketplace/components/AgentInfo/useAgentInfo.ts @@ -6,7 +6,6 @@ import { useToast } from "@/components/molecules/Toast/use-toast"; import { useRouter } from "next/navigation"; import * as Sentry from "@sentry/nextjs"; import { useGetV2DownloadAgentFile } from "@/app/api/__generated__/endpoints/store/store"; -import { useOnboarding } from "@/providers/onboarding/onboarding-provider"; import { analytics } from "@/services/analytics"; import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent"; import { useQueryClient } from "@tanstack/react-query"; @@ -18,7 +17,6 @@ interface UseAgentInfoProps { export const useAgentInfo = ({ storeListingVersionId }: UseAgentInfoProps) => { const { toast } = useToast(); const router = useRouter(); - const { completeStep } = useOnboarding(); const queryClient = useQueryClient(); const { @@ -49,8 +47,6 @@ export const useAgentInfo = ({ storeListingVersionId }: UseAgentInfoProps) => { const data = response as LibraryAgent; if (isAddingAgentFirstTime) { - completeStep("MARKETPLACE_ADD_AGENT"); - await queryClient.invalidateQueries({ queryKey: getGetV2ListLibraryAgentsQueryKey(), }); diff --git a/autogpt_platform/frontend/src/app/(platform)/monitoring/components/AgentImportForm.tsx b/autogpt_platform/frontend/src/app/(platform)/monitoring/components/AgentImportForm.tsx index 991ea4868a2d..7692640d2f69 100644 --- a/autogpt_platform/frontend/src/app/(platform)/monitoring/components/AgentImportForm.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/monitoring/components/AgentImportForm.tsx @@ -62,7 +62,7 @@ export const AgentImportForm: React.FC< }; api - .createGraph(payload) + .createGraph(payload, "upload") .then((response) => { const qID = "flowID"; window.location.href = `/build?${qID}=${response.id}`; diff --git a/autogpt_platform/frontend/src/app/api/helpers.ts b/autogpt_platform/frontend/src/app/api/helpers.ts index 30bca0e1f088..2ed45c9517ff 100644 --- a/autogpt_platform/frontend/src/app/api/helpers.ts +++ b/autogpt_platform/frontend/src/app/api/helpers.ts @@ -1,4 +1,7 @@ -import BackendAPI from "@/lib/autogpt-server-api"; +import { + getV1IsOnboardingEnabled, + getV1OnboardingState, +} from "./__generated__/endpoints/onboarding/onboarding"; /** * Narrow an orval response to its success payload if and only if it is a `200` status with OK shape. @@ -26,10 +29,67 @@ export function okData(res: unknown): T | undefined { return (res as { data: T }).data; } +type ResponseWithData = { status: number; data: unknown }; +type ExtractResponseData = T extends { + data: infer D; +} + ? D + : never; +type SuccessfulResponses = T extends { + status: infer S; +} + ? S extends number + ? `${S}` extends `2${string}` + ? T + : never + : never + : never; + +/** + * Resolve an Orval response to its payload after asserting the status is either the explicit + * `expected` code or any other 2xx status if `expected` is omitted. + * + * Usage with server actions: + * ```ts + * const onboarding = await expectStatus(getV1OnboardingState()); + * const agent = await expectStatus( + * postV2AddMarketplaceAgent({ store_listing_version_id }), + * 201, + * ); + * ``` + */ +export function resolveResponse< + TSuccess extends ResponseWithData, + TCode extends number, +>( + promise: Promise, + expected: TCode, +): Promise>>; +export function resolveResponse( + promise: Promise, +): Promise>>; +export async function resolveResponse< + TSuccess extends ResponseWithData, + TCode extends number, +>(promise: Promise, expected?: TCode) { + const res = await promise; + const isSuccessfulStatus = + typeof res.status === "number" && res.status >= 200 && res.status < 300; + + if (typeof expected === "number") { + if (res.status !== expected) { + throw new Error(`Unexpected status ${res.status}`); + } + } else if (!isSuccessfulStatus) { + throw new Error(`Unexpected status ${res.status}`); + } + + return res.data; +} + export async function shouldShowOnboarding() { - const api = new BackendAPI(); - const isEnabled = await api.isOnboardingEnabled(); - const onboarding = await api.getUserOnboarding(); + const isEnabled = await resolveResponse(getV1IsOnboardingEnabled()); + const onboarding = await resolveResponse(getV1OnboardingState()); const isCompleted = onboarding.completedSteps.includes("CONGRATS"); return isEnabled && !isCompleted; } diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index 49ef8e607f9f..2ffeb4042862 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -827,12 +827,16 @@ "/api/onboarding": { "get": { "tags": ["v1", "onboarding"], - "summary": "Get onboarding status", - "operationId": "getV1Get onboarding status", + "summary": "Onboarding state", + "operationId": "getV1Onboarding state", "responses": { "200": { "description": "Successful Response", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UserOnboarding" } + } + } }, "401": { "$ref": "#/components/responses/HTTP401NotAuthenticatedError" @@ -842,8 +846,8 @@ }, "patch": { "tags": ["v1", "onboarding"], - "summary": "Update onboarding progress", - "operationId": "patchV1Update onboarding progress", + "summary": "Update onboarding state", + "operationId": "patchV1Update onboarding state", "requestBody": { "content": { "application/json": { @@ -855,7 +859,11 @@ "responses": { "200": { "description": "Successful Response", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UserOnboarding" } + } + } }, "422": { "description": "Validation Error", @@ -872,15 +880,70 @@ "security": [{ "HTTPBearerJWT": [] }] } }, + "/api/onboarding/step": { + "post": { + "tags": ["v1", "onboarding"], + "summary": "Complete onboarding step", + "operationId": "postV1Complete onboarding step", + "security": [{ "HTTPBearerJWT": [] }], + "parameters": [ + { + "name": "step", + "in": "query", + "required": true, + "schema": { + "enum": [ + "WELCOME", + "USAGE_REASON", + "INTEGRATIONS", + "AGENT_CHOICE", + "AGENT_NEW_RUN", + "AGENT_INPUT", + "CONGRATS", + "MARKETPLACE_VISIT", + "BUILDER_OPEN" + ], + "type": "string", + "title": "Step" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { "application/json": { "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + }, + "401": { + "$ref": "#/components/responses/HTTP401NotAuthenticatedError" + } + } + } + }, "/api/onboarding/agents": { "get": { "tags": ["v1", "onboarding"], - "summary": "Get recommended agents", - "operationId": "getV1Get recommended agents", + "summary": "Recommended onboarding agents", + "operationId": "getV1Recommended onboarding agents", "responses": { "200": { "description": "Successful Response", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": { + "items": { "$ref": "#/components/schemas/StoreAgentDetails" }, + "type": "array", + "title": "Response Getv1Recommended Onboarding Agents" + } + } + } }, "401": { "$ref": "#/components/responses/HTTP401NotAuthenticatedError" @@ -892,12 +955,19 @@ "/api/onboarding/enabled": { "get": { "tags": ["v1", "onboarding", "public"], - "summary": "Check onboarding enabled", - "operationId": "getV1Check onboarding enabled", + "summary": "Is onboarding enabled", + "operationId": "getV1Is onboarding enabled", "responses": { "200": { "description": "Successful Response", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": { + "type": "boolean", + "title": "Response Getv1Is Onboarding Enabled" + } + } + } }, "401": { "$ref": "#/components/responses/HTTP401NotAuthenticatedError" @@ -914,7 +984,11 @@ "responses": { "200": { "description": "Successful Response", - "content": { "application/json": { "schema": {} } } + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UserOnboarding" } + } + } }, "401": { "$ref": "#/components/responses/HTTP401NotAuthenticatedError" @@ -5423,6 +5497,16 @@ }, "type": "object", "title": "Credentials Inputs" + }, + "source": { + "anyOf": [ + { + "type": "string", + "enum": ["builder", "library", "onboarding"] + }, + { "type": "null" } + ], + "title": "Source" } }, "type": "object", @@ -5470,6 +5554,12 @@ "store_listing_version_id": { "type": "string", "title": "Store Listing Version Id" + }, + "source": { + "type": "string", + "enum": ["onboarding", "marketplace"], + "title": "Source", + "default": "marketplace" } }, "type": "object", @@ -5577,7 +5667,16 @@ "title": "CreateAPIKeyResponse" }, "CreateGraph": { - "properties": { "graph": { "$ref": "#/components/schemas/Graph" } }, + "properties": { + "graph": { "$ref": "#/components/schemas/Graph" }, + "source": { + "anyOf": [ + { "type": "string", "enum": ["builder", "upload"] }, + { "type": "null" } + ], + "title": "Source" + } + }, "type": "object", "required": ["graph"], "title": "CreateGraph" @@ -9786,18 +9885,85 @@ "title": "UserHistoryResponse", "description": "Response model for listings with version history" }, - "UserOnboardingUpdate": { + "UserOnboarding": { "properties": { + "userId": { "type": "string", "title": "Userid" }, "completedSteps": { + "items": { "$ref": "#/components/schemas/OnboardingStep" }, + "type": "array", + "title": "Completedsteps" + }, + "walletShown": { "type": "boolean", "title": "Walletshown" }, + "notified": { + "items": { "$ref": "#/components/schemas/OnboardingStep" }, + "type": "array", + "title": "Notified" + }, + "rewardedFor": { + "items": { "$ref": "#/components/schemas/OnboardingStep" }, + "type": "array", + "title": "Rewardedfor" + }, + "usageReason": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "title": "Usagereason" + }, + "integrations": { + "items": { "type": "string" }, + "type": "array", + "title": "Integrations" + }, + "otherIntegrations": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "title": "Otherintegrations" + }, + "selectedStoreListingVersionId": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "title": "Selectedstorelistingversionid" + }, + "agentInput": { + "additionalProperties": true, + "type": "object", + "title": "Agentinput" + }, + "onboardingAgentExecutionId": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "title": "Onboardingagentexecutionid" + }, + "agentRuns": { "type": "integer", "title": "Agentruns" }, + "lastRunAt": { "anyOf": [ - { - "items": { "$ref": "#/components/schemas/OnboardingStep" }, - "type": "array" - }, + { "type": "string", "format": "date-time" }, { "type": "null" } ], - "title": "Completedsteps" + "title": "Lastrunat" }, + "consecutiveRunDays": { + "type": "integer", + "title": "Consecutiverundays" + } + }, + "type": "object", + "required": [ + "userId", + "completedSteps", + "walletShown", + "notified", + "rewardedFor", + "usageReason", + "integrations", + "otherIntegrations", + "selectedStoreListingVersionId", + "agentInput", + "onboardingAgentExecutionId", + "agentRuns", + "lastRunAt", + "consecutiveRunDays" + ], + "title": "UserOnboarding" + }, + "UserOnboardingUpdate": { + "properties": { "walletShown": { "anyOf": [{ "type": "boolean" }, { "type": "null" }], "title": "Walletshown" @@ -9841,21 +10007,6 @@ "onboardingAgentExecutionId": { "anyOf": [{ "type": "string" }, { "type": "null" }], "title": "Onboardingagentexecutionid" - }, - "agentRuns": { - "anyOf": [{ "type": "integer" }, { "type": "null" }], - "title": "Agentruns" - }, - "lastRunAt": { - "anyOf": [ - { "type": "string", "format": "date-time" }, - { "type": "null" } - ], - "title": "Lastrunat" - }, - "consecutiveRunDays": { - "anyOf": [{ "type": "integer" }, { "type": "null" }], - "title": "Consecutiverundays" } }, "type": "object", diff --git a/autogpt_platform/frontend/src/components/layout/Navbar/components/Wallet/Wallet.tsx b/autogpt_platform/frontend/src/components/layout/Navbar/components/Wallet/Wallet.tsx index 4e7d58e9242b..0a3c7de6c8db 100644 --- a/autogpt_platform/frontend/src/components/layout/Navbar/components/Wallet/Wallet.tsx +++ b/autogpt_platform/frontend/src/components/layout/Navbar/components/Wallet/Wallet.tsx @@ -250,31 +250,41 @@ export function Wallet() { [], ); - // Confetti effect on the wallet button + // React to onboarding notifications emitted by the provider const handleNotification = useCallback( (notification: WebSocketNotification) => { - if (notification.type !== "onboarding") { + if ( + notification.type !== "onboarding" || + notification.event !== "step_completed" || + !walletRef.current + ) { return; } - if (walletRef.current) { - // Fix confetti appearing in the top left corner - const rect = walletRef.current.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) { - return; - } - fetchCredits(); - party.confetti(walletRef.current!, { - count: 30, - spread: 120, - shapes: ["square", "circle"], - size: party.variation.range(1, 2), - speed: party.variation.range(200, 300), - modules: [fadeOut], - }); + // Only trigger confetti for tasks that are in groups + const taskIds = groups + .flatMap((group) => group.tasks) + .map((task) => task.id); + if (!taskIds.includes(notification.step as OnboardingStep)) { + return; } + + const rect = walletRef.current.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return; + } + + fetchCredits(); + party.confetti(walletRef.current, { + count: 30, + spread: 120, + shapes: ["square", "circle"], + size: party.variation.range(1, 2), + speed: party.variation.range(200, 300), + modules: [fadeOut], + }); }, - [], + [fetchCredits, fadeOut], ); // WebSocket setup for onboarding notifications diff --git a/autogpt_platform/frontend/src/components/layout/Navbar/components/Wallet/components/WalletTaskGroups.tsx b/autogpt_platform/frontend/src/components/layout/Navbar/components/Wallet/components/WalletTaskGroups.tsx index 669635a749ab..9b8471493b6c 100644 --- a/autogpt_platform/frontend/src/components/layout/Navbar/components/Wallet/components/WalletTaskGroups.tsx +++ b/autogpt_platform/frontend/src/components/layout/Navbar/components/Wallet/components/WalletTaskGroups.tsx @@ -16,7 +16,10 @@ export function TaskGroups({ groups }: Props) { const [openGroups, setOpenGroups] = useState>(() => { const initialState: Record = {}; groups.forEach((group) => { - initialState[group.name] = true; + const completed = group.tasks.every((task) => + state?.completedSteps?.includes(task.id), + ); + initialState[group.name] = !completed; }); return initialState; }); @@ -62,7 +65,7 @@ export function TaskGroups({ groups }: Props) { {} as Record, ), ); - }, [state?.completedSteps, isGroupCompleted]); + }, [state?.completedSteps, isGroupCompleted, groups]); const setRef = (name: string) => (el: HTMLDivElement | null) => { if (el) { @@ -101,9 +104,10 @@ export function TaskGroups({ groups }: Props) { useEffect(() => { groups.forEach((group) => { const groupCompleted = isGroupCompleted(group); - // Check if the last task in the group is completed - const alreadyCelebrated = state?.notified.includes( - group.tasks[group.tasks.length - 1].id, + // Check if all tasks in the group were already celebrated + // last task completed triggers group completion + const alreadyCelebrated = group.tasks.every((task) => + state?.notified.includes(task.id), ); if (groupCompleted) { diff --git a/autogpt_platform/frontend/src/hooks/useAgentGraph.tsx b/autogpt_platform/frontend/src/hooks/useAgentGraph.tsx index f3b6731aa610..aef9196062c9 100644 --- a/autogpt_platform/frontend/src/hooks/useAgentGraph.tsx +++ b/autogpt_platform/frontend/src/hooks/useAgentGraph.tsx @@ -26,7 +26,6 @@ import { default as NextLink } from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag"; -import { useOnboarding } from "@/providers/onboarding/onboarding-provider"; import { useQueryClient } from "@tanstack/react-query"; import { getGetV2ListLibraryAgentsQueryKey } from "@/app/api/__generated__/endpoints/library/library"; @@ -64,7 +63,6 @@ export default function useAgentGraph( ); const [xyNodes, setXYNodes] = useState([]); const [xyEdges, setXYEdges] = useState([]); - const { state, completeStep, incrementRuns } = useOnboarding(); const betaBlocks = useGetFlag(Flag.BETA_BLOCKS); // Filter blocks based on beta flags @@ -554,14 +552,13 @@ export default function useAgentGraph( setIsRunning(false); setIsStopping(false); setActiveExecutionID(null); - incrementRuns(); } }, ); }; fetchExecutions(); - }, [flowID, flowExecutionID, incrementRuns]); + }, [flowID, flowExecutionID]); const prepareNodeInputData = useCallback( (node: CustomNode) => { @@ -670,7 +667,7 @@ export default function useAgentGraph( ...payload, id: savedAgent.id, }) - : await api.createGraph(payload); + : await api.createGraph(payload, "builder"); console.debug("Response from the API:", newSavedAgent); } @@ -743,8 +740,6 @@ export default function useAgentGraph( await queryClient.invalidateQueries({ queryKey: getGetV2ListLibraryAgentsQueryKey(), }); - - completeStep("BUILDER_SAVE_AGENT"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -757,7 +752,7 @@ export default function useAgentGraph( } finally { setIsSaving(false); } - }, [_saveAgent, toast, completeStep]); + }, [_saveAgent, toast]); const saveAndRun = useCallback( async ( @@ -772,7 +767,6 @@ export default function useAgentGraph( let savedAgent: Graph; try { savedAgent = await _saveAgent(); - completeStep("BUILDER_SAVE_AGENT"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -800,6 +794,7 @@ export default function useAgentGraph( savedAgent.version, inputs, credentialsInputs, + "builder", ); setActiveExecutionID(graphExecution.id); @@ -810,10 +805,6 @@ export default function useAgentGraph( path.set("flowVersion", savedAgent.version.toString()); path.set("flowExecutionID", graphExecution.id); router.push(`${pathname}?${path.toString()}`); - - if (state?.completedSteps.includes("BUILDER_SAVE_AGENT")) { - completeStep("BUILDER_RUN_AGENT"); - } } catch (error) { // Check if this is a structured validation error from the backend if (error instanceof ApiError && error.isGraphValidationError()) { @@ -863,12 +854,10 @@ export default function useAgentGraph( [ _saveAgent, toast, - completeStep, api, searchParams, pathname, router, - state, isSaving, isRunning, processedUpdates, diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts index f410c8d657fd..3b0666bf6235 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/client.ts @@ -55,7 +55,6 @@ import type { Schedule, ScheduleCreatable, ScheduleID, - StoreAgentDetails, StoreAgentsResponse, StoreListingsWithVersionsResponse, StoreReview, @@ -66,7 +65,6 @@ import type { SubmissionStatus, TransactionHistory, User, - UserOnboarding, UserPasswordCredentials, UsersBalanceHistoryResponse, WebSocketNotification, @@ -193,29 +191,6 @@ export default class BackendAPI { return this._request("PATCH", "/credits"); } - //////////////////////////////////////// - ////////////// ONBOARDING ////////////// - //////////////////////////////////////// - - getUserOnboarding(): Promise { - return this._get("/onboarding"); - } - - updateUserOnboarding( - onboarding: Omit, "rewardedFor">, - ): Promise { - return this._request("PATCH", "/onboarding", onboarding); - } - - getOnboardingAgents(): Promise { - return this._get("/onboarding/agents"); - } - - /** Check if onboarding is enabled not if user finished it or not. */ - isOnboardingEnabled(): Promise { - return this._get("/onboarding/enabled"); - } - //////////////////////////////////////// //////////////// GRAPHS //////////////// //////////////////////////////////////// @@ -249,8 +224,14 @@ export default class BackendAPI { return this._get(`/graphs/${id}/versions`); } - createGraph(graph: GraphCreatable): Promise { - const requestBody = { graph } as GraphCreateRequestBody; + createGraph( + graph: GraphCreatable, + source?: GraphCreationSource, + ): Promise { + const requestBody: GraphCreateRequestBody = { graph }; + if (source) { + requestBody.source = source; + } return this._request("POST", "/graphs", requestBody); } @@ -274,11 +255,13 @@ export default class BackendAPI { version: number, inputs: { [key: string]: any } = {}, credentials_inputs: { [key: string]: CredentialsMetaInput } = {}, + source?: GraphExecutionSource, ): Promise { - return this._request("POST", `/graphs/${id}/execute/${version}`, { - inputs, - credentials_inputs, - }); + const body: GraphExecuteRequestBody = { inputs, credentials_inputs }; + if (source) { + body.source = source; + } + return this._request("POST", `/graphs/${id}/execute/${version}`, body); } getExecutions(): Promise { @@ -468,29 +451,12 @@ export default class BackendAPI { return this._get("/store/agents", params); } - getStoreAgent( - username: string, - agentName: string, - ): Promise { - return this._get( - `/store/agents/${encodeURIComponent(username)}/${encodeURIComponent( - agentName, - )}`, - ); - } - getGraphMetaByStoreListingVersionID( storeListingVersionID: string, ): Promise { return this._get(`/store/graph/${storeListingVersionID}`); } - getStoreAgentByVersionId( - storeListingVersionID: string, - ): Promise { - return this._get(`/store/agents/${storeListingVersionID}`); - } - getStoreCreators(params?: { featured?: boolean; search_query?: string; @@ -689,14 +655,6 @@ export default class BackendAPI { }); } - addMarketplaceAgentToLibrary( - storeListingVersionID: string, - ): Promise { - return this._request("POST", "/library/agents", { - store_listing_version_id: storeListingVersionID, - }); - } - updateLibraryAgent( libraryAgentId: LibraryAgentID, params: { @@ -1356,8 +1314,18 @@ declare global { /* *** UTILITY TYPES *** */ +type GraphCreationSource = "builder" | "upload"; +type GraphExecutionSource = "builder" | "library" | "onboarding"; + type GraphCreateRequestBody = { graph: GraphCreatable; + source?: GraphCreationSource; +}; + +type GraphExecuteRequestBody = { + inputs: { [key: string]: any }; + credentials_inputs: { [key: string]: CredentialsMetaInput }; + source?: GraphExecutionSource; }; type WebsocketMessageTypeMap = { diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts index ea82e34d3ed1..3a3a219e8545 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts @@ -711,28 +711,6 @@ export type StoreAgentsResponse = { pagination: Pagination; }; -export type StoreAgentDetails = { - store_listing_version_id: string; - slug: string; - updated_at: string; - agent_name: string; - agent_video: string; - agent_image: string[]; - creator: string; - creator_avatar: string; - sub_heading: string; - description: string; - categories: string[]; - runs: number; - rating: number; - versions: string[]; - - // Approval and status fields - active_version_id?: string; - has_approved_version?: boolean; - is_available?: boolean; -}; - export type Creator = { name: string; username: string; @@ -978,8 +956,8 @@ export interface UserOnboarding { export interface OnboardingNotificationPayload { type: "onboarding"; - event: string; - step: OnboardingStep; + event: "step_completed" | "increment_runs"; + step: OnboardingStep | null; } export type WebSocketNotification = diff --git a/autogpt_platform/frontend/src/providers/onboarding/helpers.ts b/autogpt_platform/frontend/src/providers/onboarding/helpers.ts index 34d7e6f5d059..6a05315383a4 100644 --- a/autogpt_platform/frontend/src/providers/onboarding/helpers.ts +++ b/autogpt_platform/frontend/src/providers/onboarding/helpers.ts @@ -1,78 +1,32 @@ -import { OnboardingStep, UserOnboarding } from "@/lib/autogpt-server-api"; +import { + GraphExecutionID, + OnboardingStep, + UserOnboarding, +} from "@/lib/autogpt-server-api"; +import { UserOnboarding as RawUserOnboarding } from "@/app/api/__generated__/models/userOnboarding"; -export function isToday(date: Date): boolean { - const today = new Date(); - return ( - date.getDate() === today.getDate() && - date.getMonth() === today.getMonth() && - date.getFullYear() === today.getFullYear() - ); -} - -export function isYesterday(date: Date): boolean { - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - - return ( - date.getDate() === yesterday.getDate() && - date.getMonth() === yesterday.getMonth() && - date.getFullYear() === yesterday.getFullYear() - ); -} - -export function calculateConsecutiveDays( - lastRunAt: Date | null, - currentConsecutiveDays: number, -): { lastRunAt: Date; consecutiveRunDays: number } { - const now = new Date(); - - if (lastRunAt === null || isYesterday(lastRunAt)) { - return { - lastRunAt: now, - consecutiveRunDays: currentConsecutiveDays + 1, - }; - } - - if (!isToday(lastRunAt)) { - return { - lastRunAt: now, - consecutiveRunDays: 1, - }; - } +export type LocalOnboardingStateUpdate = Omit< + Partial, + | "completedSteps" + | "rewardedFor" + | "lastRunAt" + | "consecutiveRunDays" + | "agentRuns" +>; - return { - lastRunAt: now, - consecutiveRunDays: currentConsecutiveDays, - }; -} - -export function getRunMilestoneSteps( - newRunCount: number, - consecutiveDays: number, -): OnboardingStep[] { - const steps: OnboardingStep[] = []; - - if (newRunCount >= 10) steps.push("RUN_AGENTS"); - if (newRunCount >= 100) steps.push("RUN_AGENTS_100"); - if (consecutiveDays >= 3) steps.push("RUN_3_DAYS"); - if (consecutiveDays >= 14) steps.push("RUN_14_DAYS"); - - return steps; -} - -export function processOnboardingData( - onboarding: UserOnboarding, +export function fromBackendUserOnboarding( + onboarding: RawUserOnboarding, ): UserOnboarding { - // Patch for TRIGGER_WEBHOOK - only set on backend then overwritten by frontend - const completeWebhook = - onboarding.rewardedFor.includes("TRIGGER_WEBHOOK") && - !onboarding.completedSteps.includes("TRIGGER_WEBHOOK") - ? (["TRIGGER_WEBHOOK"] as OnboardingStep[]) - : []; - return { ...onboarding, - completedSteps: [...completeWebhook, ...onboarding.completedSteps], + usageReason: onboarding.usageReason || null, + otherIntegrations: onboarding.otherIntegrations || null, + selectedStoreListingVersionId: + onboarding.selectedStoreListingVersionId || null, + agentInput: + (onboarding.agentInput as Record) || null, + onboardingAgentExecutionId: + (onboarding.onboardingAgentExecutionId as GraphExecutionID) || null, lastRunAt: onboarding.lastRunAt ? new Date(onboarding.lastRunAt) : null, }; } @@ -87,23 +41,30 @@ export function shouldRedirectFromOnboarding( ); } -export function createInitialOnboardingState( - newState: Omit, "rewardedFor">, -): UserOnboarding { +export function updateOnboardingState( + prevState: UserOnboarding | null, + newState: LocalOnboardingStateUpdate, +): UserOnboarding | null { return { - completedSteps: [], - walletShown: true, - notified: [], - rewardedFor: [], - usageReason: null, - integrations: [], - otherIntegrations: null, - selectedStoreListingVersionId: null, - agentInput: null, - onboardingAgentExecutionId: null, - agentRuns: 0, - lastRunAt: null, - consecutiveRunDays: 0, - ...newState, + completedSteps: prevState?.completedSteps ?? [], + walletShown: newState.walletShown ?? prevState?.walletShown ?? false, + notified: newState.notified ?? prevState?.notified ?? [], + rewardedFor: prevState?.rewardedFor ?? [], + usageReason: newState.usageReason ?? prevState?.usageReason ?? null, + integrations: newState.integrations ?? prevState?.integrations ?? [], + otherIntegrations: + newState.otherIntegrations ?? prevState?.otherIntegrations ?? null, + selectedStoreListingVersionId: + newState.selectedStoreListingVersionId ?? + prevState?.selectedStoreListingVersionId ?? + null, + agentInput: newState.agentInput ?? prevState?.agentInput ?? null, + onboardingAgentExecutionId: + newState.onboardingAgentExecutionId ?? + prevState?.onboardingAgentExecutionId ?? + null, + lastRunAt: prevState?.lastRunAt ?? null, + consecutiveRunDays: prevState?.consecutiveRunDays ?? 0, + agentRuns: prevState?.agentRuns ?? 0, }; } diff --git a/autogpt_platform/frontend/src/providers/onboarding/onboarding-provider.tsx b/autogpt_platform/frontend/src/providers/onboarding/onboarding-provider.tsx index 1a28aebdf93d..ebd972d1bde9 100644 --- a/autogpt_platform/frontend/src/providers/onboarding/onboarding-provider.tsx +++ b/autogpt_platform/frontend/src/providers/onboarding/onboarding-provider.tsx @@ -10,7 +10,10 @@ import { } from "@/components/__legacy__/ui/dialog"; import { useToast } from "@/components/molecules/Toast/use-toast"; import { useOnboardingTimezoneDetection } from "@/hooks/useOnboardingTimezoneDetection"; -import { OnboardingStep, UserOnboarding } from "@/lib/autogpt-server-api"; +import { + UserOnboarding, + WebSocketNotification, +} from "@/lib/autogpt-server-api"; import { useBackendAPI } from "@/lib/autogpt-server-api/context"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; import Link from "next/link"; @@ -25,28 +28,37 @@ import { useState, } from "react"; import { - calculateConsecutiveDays, - createInitialOnboardingState, - getRunMilestoneSteps, - processOnboardingData, + updateOnboardingState, + fromBackendUserOnboarding, shouldRedirectFromOnboarding, + LocalOnboardingStateUpdate, } from "./helpers"; +import { resolveResponse } from "@/app/api/helpers"; +import { + getV1IsOnboardingEnabled, + getV1OnboardingState, + patchV1UpdateOnboardingState, + postV1CompleteOnboardingStep, +} from "@/app/api/__generated__/endpoints/onboarding/onboarding"; +import { PostV1CompleteOnboardingStepStep } from "@/app/api/__generated__/models/postV1CompleteOnboardingStepStep"; + +type FrontendOnboardingStep = PostV1CompleteOnboardingStepStep; const OnboardingContext = createContext< | { state: UserOnboarding | null; - updateState: ( - state: Omit, "rewardedFor">, - ) => void; + updateState: (state: LocalOnboardingStateUpdate) => void; step: number; setStep: (step: number) => void; - completeStep: (step: OnboardingStep) => void; - incrementRuns: () => void; + completeStep: (step: FrontendOnboardingStep) => void; } | undefined >(undefined); -export function useOnboarding(step?: number, completeStep?: OnboardingStep) { +export function useOnboarding( + step?: number, + completeStep?: FrontendOnboardingStep, +) { const context = useContext(OnboardingContext); if (!context) @@ -56,15 +68,13 @@ export function useOnboarding(step?: number, completeStep?: OnboardingStep) { if ( !completeStep || !context.state || - !context.state.completedSteps || context.state.completedSteps.includes(completeStep) - ) + ) { return; + } - context.updateState({ - completedSteps: [...context.state.completedSteps, completeStep], - }); - }, [completeStep, context, context.updateState]); + context.completeStep(completeStep); + }, [completeStep, context]); useEffect(() => { if (step && context.step !== step) { @@ -113,6 +123,15 @@ export default function OnboardingProvider({ const isOnOnboardingRoute = pathname.startsWith("/onboarding"); + const fetchOnboarding = useCallback(async () => { + const onboarding = await resolveResponse(getV1OnboardingState()); + const processedOnboarding = fromBackendUserOnboarding(onboarding); + if (isMounted.current) { + setState(processedOnboarding); + } + return processedOnboarding; + }, []); + useEffect(() => { // Prevent multiple initializations if (hasInitialized.current || !isLoggedIn) { @@ -125,26 +144,19 @@ export default function OnboardingProvider({ try { // Check onboarding enabled only for onboarding routes if (isOnOnboardingRoute) { - const enabled = await api.isOnboardingEnabled(); + const enabled = await resolveResponse(getV1IsOnboardingEnabled()); if (!enabled) { router.push("/marketplace"); return; } } - const onboarding = await api.getUserOnboarding(); - if (!onboarding) return; - - const processedOnboarding = processOnboardingData(onboarding); - setState(processedOnboarding); + const onboarding = await fetchOnboarding(); // Handle redirects for completed onboarding if ( isOnOnboardingRoute && - shouldRedirectFromOnboarding( - processedOnboarding.completedSteps, - pathname, - ) + shouldRedirectFromOnboarding(onboarding.completedSteps, pathname) ) { router.push("/marketplace"); } @@ -163,21 +175,53 @@ export default function OnboardingProvider({ initializeOnboarding(); }, [api, isOnOnboardingRoute, router, isLoggedIn, pathname]); - const updateState = useCallback( - (newState: Omit, "rewardedFor">) => { - if (!isLoggedIn || !isMounted.current) return; + const handleOnboardingNotification = useCallback( + (notification: WebSocketNotification) => { + if (!isLoggedIn || notification.type !== "onboarding") { + return; + } - // Update local state immediately - setState((prev) => { - if (!prev) { - return createInitialOnboardingState(newState); - } - return { ...prev, ...newState }; + if (notification.step === "RUN_AGENTS") { + setNpsDialogOpen(true); + } + + fetchOnboarding().catch((error) => { + console.error( + "Failed to refresh onboarding after notification:", + error, + ); }); + }, + [fetchOnboarding, isLoggedIn], + ); + + useEffect(() => { + const detachMessage = api.onWebSocketMessage( + "notification", + handleOnboardingNotification, + ); + + if (isLoggedIn) { + api.connectWebSocket(); + } + + return () => { + detachMessage(); + }; + }, [api, handleOnboardingNotification, isLoggedIn]); + + const updateState = useCallback( + (newState: LocalOnboardingStateUpdate) => { + if (!isLoggedIn) { + return; + } + + setState((prev) => updateOnboardingState(prev, newState)); const updatePromise = (async () => { try { - await api.updateUserOnboarding(newState); + if (!isMounted.current) return; + await patchV1UpdateOnboardingState(newState); } catch (error) { console.error("Failed to update user onboarding:", error); @@ -188,58 +232,54 @@ export default function OnboardingProvider({ } })(); - // Track this pending update pendingUpdatesRef.current.add(updatePromise); updatePromise.finally(() => { pendingUpdatesRef.current.delete(updatePromise); }); }, - [api, isLoggedIn, isMounted], + [toast, isLoggedIn, fetchOnboarding, api, setState], ); const completeStep = useCallback( - (step: OnboardingStep) => { - if (!state?.completedSteps?.includes(step)) { - updateState({ - completedSteps: [...(state?.completedSteps || []), step], - }); + (step: FrontendOnboardingStep) => { + if (!isLoggedIn || state?.completedSteps?.includes(step)) { + return; } - }, - [state?.completedSteps, updateState], - ); - - const incrementRuns = useCallback(() => { - if (!state?.completedSteps) return; - - const newRunCount = state.agentRuns + 1; - const consecutiveData = calculateConsecutiveDays( - state.lastRunAt, - state.consecutiveRunDays, - ); - const milestoneSteps = getRunMilestoneSteps( - newRunCount, - consecutiveData.consecutiveRunDays, - ); + const completionPromise = (async () => { + try { + await postV1CompleteOnboardingStep({ step }); + await fetchOnboarding(); + } catch (error) { + if (isMounted.current) { + console.error("Failed to complete onboarding step:", error); + } - // Show NPS dialog at 10 runs - if (newRunCount === 10) { - setNpsDialogOpen(true); - } + toast({ + title: "Failed to complete onboarding step", + variant: "destructive", + }); + } + })(); - updateState({ - agentRuns: newRunCount, - completedSteps: Array.from( - new Set([...state.completedSteps, ...milestoneSteps]), - ), - ...consecutiveData, - }); - }, [state, updateState]); + pendingUpdatesRef.current.add(completionPromise); + completionPromise.finally(() => { + pendingUpdatesRef.current.delete(completionPromise); + }); + }, + [isLoggedIn, state?.completedSteps, fetchOnboarding, toast], + ); return (