feat(platform): Backend completion of Onboarding tasks (#11375)

Make onboarding task completion backend-authoritative which prevents
cheating (previously users could mark all tasks as completed instantly
and get rewards) and makes task completion more reliable. Completion of
tasks is moved backend with exception of introductory onboarding tasks
and visit-page type tasks.

### Changes 🏗️

- Move incrementing run counter backend and make webhook-triggered and
scheduled task execution count as well
- Use user timezone for calculating run streak
- Frontend task completion is moved from update onboarding state to
separate endpoint and guarded so only frontend tasks can be completed
- Graph creation, execution and add marketplace agent to library accept
`source`, so appropriate tasks can be completed
- Replace `client.ts` api calls with orval generated and remove no
longer used functions from `client.ts`
- Add `resolveResponse` helper function that unwraps orval generated
call result to 2xx response

Small changes&bug fixes:
- Make Redis notification bus serialize all payload fields
- Fix confetti when group is finished
- Collapse finished group when opening Wallet
- Play confetti only for tasks that are listed in the Wallet UI

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Onboarding can be finished
  - [x] All tasks can be finished and work properly
  - [x] Confetti works properly
This commit is contained in:
Krzysztof Czerwinski
2025-12-05 11:32:28 +09:00
committed by GitHub
parent 486099140d
commit c880db439d
38 changed files with 797 additions and 483 deletions

View File

@@ -22,7 +22,7 @@ from typing import (
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,
@@ -868,3 +868,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: Optional[dict[str, Any]]
onboardingAgentExecutionId: Optional[str]
agentRuns: int
lastRunAt: Optional[datetime]
consecutiveRunDays: int

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
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

View File

@@ -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,17 +9,18 @@ from prisma.enums import OnboardingStep
from prisma.models import UserOnboarding
from prisma.types import UserOnboardingCreateInput, UserOnboardingUpdateInput
from backend.data.block import get_blocks
from backend.data import execution as execution_db
from backend.data.credit import get_user_credit_model
from backend.data.model import CredentialsMetaInput
from backend.data.notification_bus import (
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 +33,20 @@ REASON_MAPPING: dict[str, list[str]] = {
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 +55,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 +93,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 +109,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 +157,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 +171,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 +239,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:
"""
@@ -268,18 +283,85 @@ def calculate_points(
return int(points)
def get_credentials_blocks() -> dict[str, str]:
# Returns a dictionary of block id to credentials field name
creds: dict[str, str] = {}
blocks = get_blocks()
for id, block in blocks.items():
for field_name, field_info in block().input_schema.model_fields.items():
if field_info.annotation == CredentialsMetaInput:
creds[id] = field_name
return creds
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)
CREDENTIALS_FIELDS: dict[str, str] = get_credentials_blocks()
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]:
@@ -288,7 +370,7 @@ async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]:
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 +418,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))

View File

@@ -26,6 +26,7 @@ from sqlalchemy import MetaData, create_engine
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} "

View File

@@ -33,7 +33,11 @@ from backend.data.model import (
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 = []

View File

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

View File

@@ -5,7 +5,7 @@ import time
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 @@ from backend.data.credit import (
set_auto_top_up,
)
from backend.data.graph import GraphSettings
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 @@ from backend.server.model import (
CreateAPIKeyRequest,
CreateAPIKeyResponse,
CreateGraph,
GraphExecutionSource,
RequestTopUp,
SetGraphActiveVersion,
TimezoneResponse,
@@ -85,6 +91,7 @@ from backend.server.model import (
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
@@ -274,9 +281,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)
@@ -284,9 +292,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
@@ -294,25 +303,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()
@@ -321,6 +344,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)
@@ -809,7 +833,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(
@@ -967,6 +996,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:
@@ -990,6 +1020,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
@@ -1103,6 +1141,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
)
@@ -1140,6 +1187,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
@@ -1316,6 +1369,8 @@ async def create_graph_execution_schedule(
result.next_run_time, user_timezone
)
await complete_onboarding_step(user_id, OnboardingStep.SCHEDULE_AGENT)
return result

View File

@@ -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__)
@@ -200,6 +202,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:
"""
@@ -217,10 +222,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(

View File

@@ -10,6 +10,7 @@ from backend.data.execution import GraphExecutionMeta
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,

View File

@@ -1,5 +1,6 @@
import datetime
import json
from unittest.mock import AsyncMock
import fastapi.testclient
import pytest
@@ -225,6 +226,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"}
@@ -239,6 +244,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):

View File

@@ -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<StoreAgentDetails[]>([]);
const api = useBackendAPI();
const router = useRouter();
useEffect(() => {
api.getOnboardingAgents().then((agents) => {
resolveResponse(getV1RecommendedOnboardingAgents()).then((agents) => {
if (agents.length < 2) {
completeStep("CONGRATS");
router.replace("/");

View File

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

View File

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

View File

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

View File

@@ -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 <LoadingSpinner size="large" cover />;
}

View File

@@ -116,7 +116,7 @@ export const useRunGraph = () => {
await executeGraph({
graphId: flowID ?? "",
graphVersion: flowVersion || null,
data: { inputs: {}, credentials_inputs: {} },
data: { inputs: {}, credentials_inputs: {}, source: "builder" },
});
}
};

View File

@@ -79,7 +79,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",
},
});
// Optimistically set running state immediately for responsive UI
setIsGraphRunning(true);

View File

@@ -83,7 +83,6 @@ export function RunnerInputDialog({
onRun={doRun ? undefined : doClose}
doCreateSchedule={doCreateSchedule ? handleSchedule : undefined}
onCreateSchedule={doCreateSchedule ? undefined : doClose}
runCount={0}
/>
</DialogContent>
</Dialog>

View File

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

View File

@@ -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({
@@ -290,6 +288,7 @@ export function useAgentRunModal(
data: {
inputs: inputValues,
credentials_inputs: inputCredentials,
source: "library",
},
});
}
@@ -335,8 +334,6 @@ export function useAgentRunModal(
userTimezone && userTimezone !== "not-set" ? userTimezone : undefined,
},
});
completeOnboardingStep("SCHEDULE_AGENT");
}, [
allRequiredInputsAreSet,
scheduleName,

View File

@@ -77,6 +77,7 @@ export function useSelectedRunActions(args: Args) {
data: {
inputs: args.run.inputs || {},
credentials_inputs: args.run.credential_inputs || {},
source: "library",
},
});

View File

@@ -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<GraphExecutionMeta | null>(null);
const [confirmingDeleteAgentPreset, setConfirmingDeleteAgentPreset] =
useState<LibraryAgentPresetID | null>(null);
const {
state: onboardingState,
updateState: updateOnboardingState,
incrementRuns,
} = useOnboarding();
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
const [creatingPresetFromExecutionID, setCreatingPresetFromExecutionID] =
useState<GraphExecutionID | null>(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<number>(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 &&

View File

@@ -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";
import { PendingReviewsList } from "@/components/organisms/PendingReviewsList/PendingReviewsList";
import { usePendingReviewsForExecution } from "@/hooks/usePendingReviews";
@@ -67,8 +66,6 @@ export function AgentRunDetailsView({
[run],
);
const { completeStep } = useOnboarding();
const {
pendingReviews,
isLoading: reviewsLoading,
@@ -166,13 +163,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"));

View File

@@ -40,9 +40,8 @@ import { cn, isEmpty } from "@/lib/utils";
import { ClockIcon, CopyIcon, InfoIcon } from "@phosphor-icons/react";
import { CalendarClockIcon, Trash2Icon } from "lucide-react";
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
import { analytics } from "@/services/analytics";
import { AgentStatus, AgentStatusChip } from "./agent-status-chip";
import { analytics } from "@/services/analytics";
export function AgentRunDraftView({
graph,
@@ -55,7 +54,6 @@ export function AgentRunDraftView({
doCreateSchedule: _doCreateSchedule,
onCreateSchedule,
agentActions,
runCount,
className,
recommendedScheduleCron,
}: {
@@ -74,7 +72,6 @@ export function AgentRunDraftView({
credentialsInputs: Record<string, CredentialsMetaInput>,
) => Promise<void>;
onCreateSchedule?: (schedule: Schedule) => void;
runCount: number;
className?: string;
} & (
| {
@@ -103,7 +100,6 @@ export function AgentRunDraftView({
const [changedPresetAttributes, setChangedPresetAttributes] = useState<
Set<keyof LibraryAgentPresetUpdatable>
>(new Set());
const { completeStep: completeOnboardingStep } = useOnboarding();
const [cronScheduleDialogOpen, setCronScheduleDialogOpen] = useState(false);
// Update values if agentPreset parameter is changed
@@ -193,7 +189,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);
@@ -203,26 +205,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;
@@ -256,7 +244,6 @@ export function AgentRunDraftView({
onCreatePreset,
toast,
toastOnFail,
completeOnboardingStep,
]);
const doUpdatePreset = useCallback(async () => {
@@ -295,7 +282,6 @@ export function AgentRunDraftView({
onUpdatePreset,
toast,
toastOnFail,
completeOnboardingStep,
]);
const doSetPresetActive = useCallback(
@@ -342,7 +328,6 @@ export function AgentRunDraftView({
onCreatePreset,
toast,
toastOnFail,
completeOnboardingStep,
]);
const openScheduleDialog = useCallback(() => {

View File

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

View File

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

View File

@@ -62,6 +62,7 @@ export const useLibraryUploadAgentDialog = () => {
await createGraph({
data: {
graph: payload,
source: "upload",
},
});
};

View File

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

View File

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

View File

@@ -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<T>(res: unknown): T | undefined {
return (res as { data: T }).data;
}
type ResponseWithData = { status: number; data: unknown };
type ExtractResponseData<T extends ResponseWithData> = T extends {
data: infer D;
}
? D
: never;
type SuccessfulResponses<T extends ResponseWithData> = 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<TSuccess>,
expected: TCode,
): Promise<ExtractResponseData<Extract<TSuccess, { status: TCode }>>>;
export function resolveResponse<TSuccess extends ResponseWithData>(
promise: Promise<TSuccess>,
): Promise<ExtractResponseData<SuccessfulResponses<TSuccess>>>;
export async function resolveResponse<
TSuccess extends ResponseWithData,
TCode extends number,
>(promise: Promise<TSuccess>, 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;
}

View File

@@ -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,16 +880,71 @@
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/onboarding/agents": {
"get": {
"/api/onboarding/step": {
"post": {
"tags": ["v1", "onboarding"],
"summary": "Get recommended agents",
"operationId": "getV1Get recommended agents",
"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": "Recommended onboarding agents",
"operationId": "getV1Recommended onboarding agents",
"responses": {
"200": {
"description": "Successful Response",
"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"
@@ -5665,6 +5739,16 @@
},
"type": "object",
"title": "Credentials Inputs"
},
"source": {
"anyOf": [
{
"type": "string",
"enum": ["builder", "library", "onboarding"]
},
{ "type": "null" }
],
"title": "Source"
}
},
"type": "object",
@@ -5712,6 +5796,12 @@
"store_listing_version_id": {
"type": "string",
"title": "Store Listing Version Id"
},
"source": {
"type": "string",
"enum": ["onboarding", "marketplace"],
"title": "Source",
"default": "marketplace"
}
},
"type": "object",
@@ -5819,7 +5909,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"
@@ -10289,18 +10388,87 @@
"title": "UserHistoryResponse",
"description": "Response model for listings with version history"
},
"UserOnboardingUpdate": {
"UserOnboarding": {
"properties": {
"userId": { "type": "string", "title": "Userid" },
"completedSteps": {
"anyOf": [
{
"items": { "$ref": "#/components/schemas/OnboardingStep" },
"type": "array"
},
{ "type": "null" }
],
"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": {
"anyOf": [
{ "additionalProperties": true, "type": "object" },
{ "type": "null" }
],
"title": "Agentinput"
},
"onboardingAgentExecutionId": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Onboardingagentexecutionid"
},
"agentRuns": { "type": "integer", "title": "Agentruns" },
"lastRunAt": {
"anyOf": [
{ "type": "string", "format": "date-time" },
{ "type": "null" }
],
"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"
@@ -10344,21 +10512,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",

View File

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

View File

@@ -16,7 +16,10 @@ export function TaskGroups({ groups }: Props) {
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() => {
const initialState: Record<string, boolean> = {};
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<string, boolean>,
),
);
}, [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) {

View File

@@ -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";
@@ -67,7 +66,6 @@ export default function useAgentGraph(
>(null);
const [xyNodes, setXYNodes] = useState<CustomNode[]>([]);
const [xyEdges, setXYEdges] = useState<CustomEdge[]>([]);
const { state, completeStep, incrementRuns } = useOnboarding();
const betaBlocks = useGetFlag(Flag.BETA_BLOCKS);
// Filter blocks based on beta flags
@@ -563,14 +561,13 @@ export default function useAgentGraph(
setIsRunning(false);
setIsStopping(false);
setActiveExecutionID(null);
incrementRuns();
}
},
);
};
fetchExecutions();
}, [flowID, flowExecutionID, incrementRuns]);
}, [flowID, flowExecutionID]);
const prepareNodeInputData = useCallback(
(node: CustomNode) => {
@@ -679,7 +676,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);
}
@@ -751,8 +748,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);
@@ -765,7 +760,7 @@ export default function useAgentGraph(
} finally {
setIsSaving(false);
}
}, [_saveAgent, toast, completeStep]);
}, [_saveAgent, toast]);
const saveAndRun = useCallback(
async (
@@ -780,7 +775,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);
@@ -808,6 +802,7 @@ export default function useAgentGraph(
savedAgent.version,
inputs,
credentialsInputs,
"builder",
);
setActiveExecutionID(graphExecution.id);
@@ -818,10 +813,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()) {
@@ -871,12 +862,10 @@ export default function useAgentGraph(
[
_saveAgent,
toast,
completeStep,
api,
searchParams,
pathname,
router,
state,
isSaving,
isRunning,
processedUpdates,

View File

@@ -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<UserOnboarding> {
return this._get("/onboarding");
}
updateUserOnboarding(
onboarding: Omit<Partial<UserOnboarding>, "rewardedFor">,
): Promise<void> {
return this._request("PATCH", "/onboarding", onboarding);
}
getOnboardingAgents(): Promise<StoreAgentDetails[]> {
return this._get("/onboarding/agents");
}
/** Check if onboarding is enabled not if user finished it or not. */
isOnboardingEnabled(): Promise<boolean> {
return this._get("/onboarding/enabled");
}
////////////////////////////////////////
//////////////// GRAPHS ////////////////
////////////////////////////////////////
@@ -249,8 +224,14 @@ export default class BackendAPI {
return this._get(`/graphs/${id}/versions`);
}
createGraph(graph: GraphCreatable): Promise<Graph> {
const requestBody = { graph } as GraphCreateRequestBody;
createGraph(
graph: GraphCreatable,
source?: GraphCreationSource,
): Promise<Graph> {
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<GraphExecutionMeta> {
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<GraphExecutionMeta[]> {
@@ -468,29 +451,12 @@ export default class BackendAPI {
return this._get("/store/agents", params);
}
getStoreAgent(
username: string,
agentName: string,
): Promise<StoreAgentDetails> {
return this._get(
`/store/agents/${encodeURIComponent(username)}/${encodeURIComponent(
agentName,
)}`,
);
}
getGraphMetaByStoreListingVersionID(
storeListingVersionID: string,
): Promise<GraphMeta> {
return this._get(`/store/graph/${storeListingVersionID}`);
}
getStoreAgentByVersionId(
storeListingVersionID: string,
): Promise<StoreAgentDetails> {
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<LibraryAgent> {
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 = {

View File

@@ -761,28 +761,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;
@@ -1028,8 +1006,8 @@ export interface UserOnboarding {
export interface OnboardingNotificationPayload {
type: "onboarding";
event: string;
step: OnboardingStep;
event: "step_completed" | "increment_runs";
step: OnboardingStep | null;
}
export type WebSocketNotification =

View File

@@ -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 type LocalOnboardingStateUpdate = Omit<
Partial<UserOnboarding>,
| "completedSteps"
| "rewardedFor"
| "lastRunAt"
| "consecutiveRunDays"
| "agentRuns"
>;
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,
};
}
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<string, string | number>) || 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<Partial<UserOnboarding>, "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,
};
}

View File

@@ -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<Partial<UserOnboarding>, "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<Partial<UserOnboarding>, "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;
}
const completionPromise = (async () => {
try {
await postV1CompleteOnboardingStep({ step });
await fetchOnboarding();
} catch (error) {
if (isMounted.current) {
console.error("Failed to complete onboarding step:", error);
}
toast({
title: "Failed to complete onboarding step",
variant: "destructive",
});
}
})();
pendingUpdatesRef.current.add(completionPromise);
completionPromise.finally(() => {
pendingUpdatesRef.current.delete(completionPromise);
});
},
[state?.completedSteps, updateState],
[isLoggedIn, state?.completedSteps, fetchOnboarding, toast],
);
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,
);
// Show NPS dialog at 10 runs
if (newRunCount === 10) {
setNpsDialogOpen(true);
}
updateState({
agentRuns: newRunCount,
completedSteps: Array.from(
new Set([...state.completedSteps, ...milestoneSteps]),
),
...consecutiveData,
});
}, [state, updateState]);
return (
<OnboardingContext.Provider
value={{ state, updateState, step, setStep, completeStep, incrementRuns }}
value={{
state,
updateState,
step,
setStep,
completeStep,
}}
>
<Dialog onOpenChange={setNpsDialogOpen} open={npsDialogOpen}>
<DialogContent>