feat(platform): Onboarding updates (#9636)

This is a follow up to
https://github.com/Significant-Gravitas/AutoGPT/pull/9511 fixing some
issues and updating onboarding.

### Changes 🏗️

- Update `UserOnboarding` data
  - Update schema and add migration
- Change `step` in `UserOnboarding` to `completedSteps` array with
`OnboardingStep` enum
- Remove `isCompleted`: this is now inferred from `completedSteps`
values
- Don't onboard if <2 marketplace agents; that prevents self-host
onboarding
- Add endpoints:
- `is_onboarding_enabled`: to check if users should be onboarded (not if
they finished onboarding); now check if there are at least 2 marketplace
agents
- `get_store_agent`: returns `StoreAgentDetails` for given
`store_listing_version_id`
  - `get_graph_meta_by_store_listing_version_id`: returns `GraphMeta`
- Add agent to Library just before running it and not when chosen and
remove code that was responsible for removing agent that wasn't run
- Move onboarding to `OnboardingProvider` (it'll be needed globally for
Phase 2)
- Multiple fixes, renames for clarity

### Checklist 📋

- [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] Don't onboard if less than 2 marketplace agents
  - [x] Avoid non-input and credentials agents
  - [x] Onboarding works and can be finished
  - [x] Onboarding resumes
  - [x] Onboarding agent runs correctly

---------

Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
This commit is contained in:
Krzysztof Czerwinski
2025-03-25 11:40:40 +01:00
committed by GitHub
parent b7ca8d9c30
commit 58bb4f92b7
27 changed files with 576 additions and 292 deletions

View File

@@ -4,16 +4,13 @@ from typing import Any, Optional
import prisma
import pydantic
from prisma import Json
from prisma.models import (
AgentGraph,
AgentGraphExecution,
StoreListingVersion,
UserOnboarding,
)
from prisma.enums import OnboardingStep
from prisma.models import UserOnboarding
from prisma.types import UserOnboardingUpdateInput
from backend.server.v2.library.db import set_is_deleted_for_library_agent
from backend.server.v2.store.db import get_store_agent_details
from backend.data.block import get_blocks
from backend.data.graph import GraphModel
from backend.data.model import CredentialsMetaInput
from backend.server.v2.store.model import StoreAgentDetails
# Mapping from user reason id to categories to search for when choosing agent to show
@@ -24,17 +21,17 @@ REASON_MAPPING: dict[str, list[str]] = {
"ai_innovation": ["development", "research"],
"personal_productivity": ["personal", "productivity"],
}
POINTS_AGENT_COUNT = 50 # Number of agents to calculate points for
MIN_AGENT_COUNT = 2 # Minimum number of marketplace agents to enable onboarding
class UserOnboardingUpdate(pydantic.BaseModel):
step: int
completedSteps: Optional[list[OnboardingStep]] = None
usageReason: Optional[str] = None
integrations: list[str] = pydantic.Field(default_factory=list)
integrations: Optional[list[str]] = None
otherIntegrations: Optional[str] = None
selectedAgentCreator: Optional[str] = None
selectedAgentSlug: Optional[str] = None
selectedStoreListingVersionId: Optional[str] = None
agentInput: Optional[dict[str, Any]] = None
isCompleted: bool = False
async def get_user_onboarding(user_id: str):
@@ -48,50 +45,18 @@ async def get_user_onboarding(user_id: str):
async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
# Get the user onboarding data
user_onboarding = await get_user_onboarding(user_id)
update: UserOnboardingUpdateInput = {
"step": data.step,
"isCompleted": data.isCompleted,
}
if data.usageReason:
update: UserOnboardingUpdateInput = {}
if data.completedSteps is not None:
update["completedSteps"] = list(set(data.completedSteps))
if data.usageReason is not None:
update["usageReason"] = data.usageReason
if data.integrations:
if data.integrations is not None:
update["integrations"] = data.integrations
if data.otherIntegrations:
if data.otherIntegrations is not None:
update["otherIntegrations"] = data.otherIntegrations
if data.selectedAgentSlug and data.selectedAgentCreator:
update["selectedAgentSlug"] = data.selectedAgentSlug
update["selectedAgentCreator"] = data.selectedAgentCreator
# Check if slug changes
if (
user_onboarding.selectedAgentCreator
and user_onboarding.selectedAgentSlug
and user_onboarding.selectedAgentSlug != data.selectedAgentSlug
):
store_agent = await get_store_agent_details(
user_onboarding.selectedAgentCreator, user_onboarding.selectedAgentSlug
)
store_listing = await StoreListingVersion.prisma().find_unique_or_raise(
where={"id": store_agent.store_listing_version_id}
)
agent_graph = await AgentGraph.prisma().find_first(
where={"id": store_listing.agentId, "version": store_listing.version}
)
execution_count = await AgentGraphExecution.prisma().count(
where={
"userId": user_id,
"agentGraphId": store_listing.agentId,
"agentGraphVersion": store_listing.version,
}
)
# If there was no execution and graph doesn't belong to the user,
# mark the agent as deleted
if execution_count == 0 and agent_graph and agent_graph.userId != user_id:
await set_is_deleted_for_library_agent(
user_id, store_listing.agentId, store_listing.agentVersion, True
)
if data.agentInput:
if data.selectedStoreListingVersionId is not None:
update["selectedStoreListingVersionId"] = data.selectedStoreListingVersionId
if data.agentInput is not None:
update["agentInput"] = Json(data.agentInput)
return await UserOnboarding.prisma().upsert(
@@ -170,6 +135,20 @@ 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
CREDENTIALS_FIELDS: dict[str, str] = get_credentials_blocks()
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 "", [])
@@ -193,31 +172,74 @@ async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]:
for word in user_onboarding.integrations
]
agents = await prisma.models.StoreAgent.prisma().find_many(
storeAgents = await prisma.models.StoreAgent.prisma().find_many(
where=prisma.types.StoreAgentWhereInput(**where_clause),
order=[
{"featured": "desc"},
{"runs": "desc"},
{"rating": "desc"},
],
take=100,
)
if len(agents) < 2:
agents += await prisma.models.StoreAgent.prisma().find_many(
agentListings = await prisma.models.StoreListingVersion.prisma().find_many(
where={
"id": {"in": [agent.storeListingVersionId for agent in storeAgents]},
},
include={"Agent": True},
)
for listing in agentListings:
agent = listing.Agent
if agent is None:
continue
graph = GraphModel.from_db(agent)
# Remove agents with empty input schema
if not graph.input_schema:
storeAgents = [
a for a in storeAgents if a.storeListingVersionId != listing.id
]
continue
# Remove agents with empty credentials
# Get nodes from this agent that have credentials
nodes = await prisma.models.AgentNode.prisma().find_many(
where={
"listing_id": {"not_in": [agent.listing_id for agent in agents]},
"agentGraphId": agent.id,
"agentBlockId": {"in": list(CREDENTIALS_FIELDS.keys())},
},
)
for node in nodes:
block_id = node.agentBlockId
field_name = CREDENTIALS_FIELDS[block_id]
# If there are no credentials or they are empty, remove the agent
# FIXME ignores default values
if (
field_name not in node.constantInput
or node.constantInput[field_name] is None
):
storeAgents = [
a for a in storeAgents if a.storeListingVersionId != listing.id
]
break
# If there are less than 2 agents, add more agents to the list
if len(storeAgents) < 2:
storeAgents += await prisma.models.StoreAgent.prisma().find_many(
where={
"listing_id": {"not_in": [agent.listing_id for agent in storeAgents]},
},
order=[
{"featured": "desc"},
{"runs": "desc"},
{"rating": "desc"},
],
take=2 - len(agents),
take=2 - len(storeAgents),
)
# Calculate points for the first 30 agents and choose the top 2
# Calculate points for the first X agents and choose the top 2
agent_points = []
for agent in agents[:50]:
for agent in storeAgents[:POINTS_AGENT_COUNT]:
points = calculate_points(
agent, categories, custom, user_onboarding.integrations
)
@@ -245,3 +267,10 @@ async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]:
)
for agent in recommended_agents
]
async def onboarding_enabled() -> bool:
count = await prisma.models.StoreAgent.prisma().count(take=MIN_AGENT_COUNT + 1)
# Onboading is enabled if there are at least 2 agents in the store
return count >= MIN_AGENT_COUNT

View File

@@ -46,6 +46,7 @@ from backend.data.onboarding import (
UserOnboardingUpdate,
get_recommended_agents,
get_user_onboarding,
onboarding_enabled,
update_user_onboarding,
)
from backend.data.user import (
@@ -189,6 +190,15 @@ async def get_onboarding_agents(
return await get_recommended_agents(user_id)
@v1_router.get(
"/onboarding/enabled",
tags=["onboarding", "public"],
dependencies=[Depends(auth_middleware)],
)
async def is_onboarding_enabled():
return await onboarding_enabled()
########################################################
##################### Blocks ###########################
########################################################

View File

@@ -371,7 +371,7 @@ async def add_store_agent_to_library(
)
try:
async with locked_transaction(f"user_trx_{user_id}"):
async with locked_transaction(f"add_agent_trx_{user_id}"):
store_listing_version = (
await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id}, include={"Agent": True}

View File

@@ -188,6 +188,90 @@ async def get_store_agent_details(
) from e
async def get_available_graph(
store_listing_version_id: str,
):
try:
# Get avaialble, non-deleted store listing version
store_listing_version = (
await prisma.models.StoreListingVersion.prisma().find_first(
where={
"id": store_listing_version_id,
"isAvailable": True,
"isDeleted": False,
},
include={"Agent": {"include": {"AgentNodes": True}}},
)
)
if not store_listing_version or not store_listing_version.Agent:
raise fastapi.HTTPException(
status_code=404,
detail=f"Store listing version {store_listing_version_id} not found",
)
graph = GraphModel.from_db(store_listing_version.Agent)
# We return graph meta, without nodes, they cannot be just removed
# because then input_schema would be empty
return {
"id": graph.id,
"version": graph.version,
"is_active": graph.is_active,
"name": graph.name,
"description": graph.description,
"input_schema": graph.input_schema,
"output_schema": graph.output_schema,
}
except Exception as e:
logger.error(f"Error getting agent: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch agent"
) from e
async def get_store_agent_by_version_id(
store_listing_version_id: str,
) -> backend.server.v2.store.model.StoreAgentDetails:
logger.debug(f"Getting store agent details for {store_listing_version_id}")
try:
agent = await prisma.models.StoreAgent.prisma().find_first(
where={"storeListingVersionId": store_listing_version_id}
)
if not agent:
logger.warning(f"Agent not found: {store_listing_version_id}")
raise backend.server.v2.store.exceptions.AgentNotFoundError(
f"Agent {store_listing_version_id} not found"
)
logger.debug(f"Found agent details for {store_listing_version_id}")
return backend.server.v2.store.model.StoreAgentDetails(
store_listing_version_id=agent.storeListingVersionId,
slug=agent.slug,
agent_name=agent.agent_name,
agent_video=agent.agent_video or "",
agent_image=agent.agent_image,
creator=agent.creator_username,
creator_avatar=agent.creator_avatar,
sub_heading=agent.sub_heading,
description=agent.description,
categories=agent.categories,
runs=agent.runs,
rating=agent.rating,
versions=agent.versions,
last_updated=agent.updated_at,
)
except backend.server.v2.store.exceptions.AgentNotFoundError:
raise
except Exception as e:
logger.error(f"Error getting store agent details: {e}")
raise backend.server.v2.store.exceptions.DatabaseError(
"Failed to fetch agent details"
) from e
async def get_store_creators(
featured: bool = False,
search_query: str | None = None,

View File

@@ -196,6 +196,55 @@ async def get_agent(username: str, agent_name: str):
)
@router.get(
"/graph/{store_listing_version_id}",
tags=["store"],
)
async def get_graph_meta_by_store_listing_version_id(
store_listing_version_id: str,
_: typing.Annotated[str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)],
):
"""
Get Agent Graph from Store Listing Version ID.
"""
try:
graph = await backend.server.v2.store.db.get_available_graph(
store_listing_version_id
)
return graph
except Exception:
logger.exception("Exception occurred whilst getting agent graph")
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving the agent graph"},
)
@router.get(
"/agents/{store_listing_version_id}",
tags=["store"],
response_model=backend.server.v2.store.model.StoreAgentDetails,
)
async def get_store_agent(
store_listing_version_id: str,
_: typing.Annotated[str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)],
):
"""
Get Store Agent Details from Store Listing Version ID.
"""
try:
agent = await backend.server.v2.store.db.get_store_agent_by_version_id(
store_listing_version_id
)
return agent
except Exception:
logger.exception("Exception occurred whilst getting store agent")
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while retrieving the store agent"},
)
@router.post(
"/agents/{username}/{agent_name}/review",
tags=["store"],

View File

@@ -0,0 +1,19 @@
-- Create OnboardingStep enum
CREATE TYPE "OnboardingStep" AS ENUM (
'WELCOME',
'USAGE_REASON',
'INTEGRATIONS',
'AGENT_CHOICE',
'AGENT_NEW_RUN',
'AGENT_INPUT',
'CONGRATS'
);
-- Modify the UserOnboarding table
ALTER TABLE "UserOnboarding"
DROP COLUMN "step",
DROP COLUMN "isCompleted",
DROP COLUMN "selectedAgentCreator",
DROP COLUMN "selectedAgentSlug",
ADD COLUMN "completedSteps" "OnboardingStep"[] DEFAULT '{}',
ADD COLUMN "selectedStoreListingVersionId" TEXT

View File

@@ -57,18 +57,26 @@ model User {
@@index([email])
}
enum OnboardingStep {
WELCOME
USAGE_REASON
INTEGRATIONS
AGENT_CHOICE
AGENT_NEW_RUN
AGENT_INPUT
CONGRATS
}
model UserOnboarding {
id String @id @default(uuid())
createdAt DateTime @default(now())
step Int @default(0)
usageReason String?
integrations String[] @default([])
otherIntegrations String?
selectedAgentCreator String?
selectedAgentSlug String?
agentInput Json?
isCompleted Boolean @default(false)
completedSteps OnboardingStep[] @default([])
usageReason String?
integrations String[] @default([])
otherIntegrations String?
selectedStoreListingVersionId String?
agentInput Json?
userId String @unique
User User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -31,6 +31,14 @@ export async function logout() {
);
}
async function shouldShowOnboarding() {
const api = new BackendAPI();
return (
!(await api.isOnboardingEnabled()) ||
(await api.getUserOnboarding()).completedSteps.includes("CONGRATS")
);
}
export async function login(values: z.infer<typeof loginFormSchema>) {
return await Sentry.withServerActionInstrumentation("login", {}, async () => {
const supabase = getServerSupabase();
@@ -49,7 +57,8 @@ export async function login(values: z.infer<typeof loginFormSchema>) {
}
await api.createUser();
if (!(await api.getUserOnboarding()).isCompleted) {
// Don't onboard if disabled or already onboarded
if (await shouldShowOnboarding()) {
revalidatePath("/onboarding", "layout");
redirect("/onboarding");
}
@@ -89,7 +98,8 @@ export async function providerLogin(provider: LoginProvider) {
}
await api.createUser();
if (!(await api.getUserOnboarding()).isCompleted) {
// Don't onboard if disabled or already onboarded
if (await shouldShowOnboarding()) {
revalidatePath("/onboarding", "layout");
redirect("/onboarding");
}

View File

@@ -1,11 +1,10 @@
"use client";
import { OnboardingText } from "@/components/onboarding/OnboardingText";
import { useOnboarding } from "../layout";
import OnboardingButton from "@/components/onboarding/OnboardingButton";
import Image from "next/image";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
export default function Page() {
// Just set step to 1
useOnboarding(1);
return (

View File

@@ -1,6 +1,5 @@
"use client";
import OnboardingButton from "@/components/onboarding/OnboardingButton";
import { useOnboarding } from "../layout";
import {
OnboardingFooter,
OnboardingHeader,
@@ -8,6 +7,8 @@ import {
} from "@/components/onboarding/OnboardingStep";
import { OnboardingText } from "@/components/onboarding/OnboardingText";
import OnboardingList from "@/components/onboarding/OnboardingList";
import { isEmptyOrWhitespace } from "@/lib/utils";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
const reasons = [
{
@@ -37,12 +38,8 @@ const reasons = [
},
];
function isEmptyOrWhitespace(str: string | undefined | null): boolean {
return !str || str.trim().length === 0;
}
export default function Page() {
const { state, setState } = useOnboarding(2);
const { state, updateState } = useOnboarding(2, "WELCOME");
return (
<OnboardingStep>
@@ -57,7 +54,7 @@ export default function Page() {
<OnboardingList
elements={reasons}
selectedId={state?.usageReason}
onSelect={(usageReason) => setState({ usageReason })}
onSelect={(usageReason) => updateState({ usageReason })}
/>
<OnboardingFooter>
<OnboardingButton

View File

@@ -6,10 +6,11 @@ import {
OnboardingFooter,
} from "@/components/onboarding/OnboardingStep";
import { OnboardingText } from "@/components/onboarding/OnboardingText";
import { useOnboarding } from "../layout";
import { OnboardingGrid } from "@/components/onboarding/OnboardingGrid";
import { useCallback } from "react";
import OnboardingInput from "@/components/onboarding/OnboardingInput";
import { isEmptyOrWhitespace } from "@/lib/utils";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
const services = [
{
@@ -109,12 +110,8 @@ const services = [
},
];
function isEmptyOrWhitespace(str: string | undefined | null): boolean {
return !str || str.trim().length === 0;
}
export default function Page() {
const { state, setState } = useOnboarding(3);
const { state, updateState } = useOnboarding(3, "USAGE_REASON");
const switchIntegration = useCallback(
(name: string) => {
@@ -126,9 +123,9 @@ export default function Page() {
? state.integrations.filter((i) => i !== name)
: [...state.integrations, name];
setState({ integrations });
updateState({ integrations });
},
[state, setState],
[state, updateState],
);
return (
@@ -161,7 +158,7 @@ export default function Page() {
className="mb-4"
placeholder="Others (please specify)"
value={state?.otherIntegrations || ""}
onChange={(otherIntegrations) => setState({ otherIntegrations })}
onChange={(otherIntegrations) => updateState({ otherIntegrations })}
/>
</div>

View File

@@ -1,6 +1,5 @@
"use client";
import OnboardingButton from "@/components/onboarding/OnboardingButton";
import { useOnboarding } from "../layout";
import {
OnboardingFooter,
OnboardingHeader,
@@ -12,13 +11,11 @@ import { useEffect, useState } from "react";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { StoreAgentDetails } from "@/lib/autogpt-server-api";
import { finishOnboarding } from "../6-congrats/actions";
function isEmptyOrWhitespace(str: string | undefined | null): boolean {
return !str || str.trim().length === 0;
}
import { isEmptyOrWhitespace } from "@/lib/utils";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
export default function Page() {
const { state, setState } = useOnboarding(4);
const { state, updateState } = useOnboarding(4, "INTEGRATIONS");
const [agents, setAgents] = useState<StoreAgentDetails[]>([]);
const api = useBackendAPI();
@@ -34,16 +31,20 @@ export default function Page() {
useEffect(() => {
// Deselect agent if it's not in the list of agents
if (
state?.selectedAgentSlug &&
!agents.some((agent) => agent.slug === state.selectedAgentSlug)
state?.selectedStoreListingVersionId &&
agents.length > 0 &&
!agents.some(
(agent) =>
agent.store_listing_version_id ===
state.selectedStoreListingVersionId,
)
) {
setState({
selectedAgentSlug: undefined,
selectedAgentCreator: undefined,
agentInput: undefined,
updateState({
selectedStoreListingVersionId: null,
agentInput: {},
});
}
}, [state?.selectedAgentSlug, setState, agents]);
}, [state?.selectedStoreListingVersionId, updateState, agents]);
return (
<OnboardingStep>
@@ -61,13 +62,14 @@ export default function Page() {
{...(agents[0] || {})}
selected={
agents[0] !== undefined
? state?.selectedAgentSlug == agents[0]?.slug
? state?.selectedStoreListingVersionId ==
agents[0]?.store_listing_version_id
: false
}
onClick={() =>
setState({
selectedAgentSlug: agents[0].slug,
selectedAgentCreator: agents[0].creator,
updateState({
selectedStoreListingVersionId: agents[0].store_listing_version_id,
agentInput: {},
})
}
/>
@@ -75,13 +77,13 @@ export default function Page() {
{...(agents[1] || {})}
selected={
agents[1] !== undefined
? state?.selectedAgentSlug == agents[1]?.slug
? state?.selectedStoreListingVersionId ==
agents[1]?.store_listing_version_id
: false
}
onClick={() =>
setState({
selectedAgentSlug: agents[1].slug,
selectedAgentCreator: agents[1].creator,
updateState({
selectedStoreListingVersionId: agents[1].store_listing_version_id,
})
}
/>
@@ -90,7 +92,7 @@ export default function Page() {
<OnboardingFooter>
<OnboardingButton
href="/onboarding/5-run"
disabled={isEmptyOrWhitespace(state?.selectedAgentSlug)}
disabled={isEmptyOrWhitespace(state?.selectedStoreListingVersionId)}
>
Next
</OnboardingButton>

View File

@@ -5,83 +5,87 @@ import {
OnboardingHeader,
} from "@/components/onboarding/OnboardingStep";
import { OnboardingText } from "@/components/onboarding/OnboardingText";
import { useOnboarding } from "../layout";
import StarRating from "@/components/onboarding/StarRating";
import { Play } from "lucide-react";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useState } from "react";
import OnboardingAgentInput from "@/components/onboarding/OnboardingAgentInput";
import Image from "next/image";
import { LibraryAgent, StoreAgentDetails } from "@/lib/autogpt-server-api";
import { GraphMeta, StoreAgentDetails } from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useRouter } from "next/navigation";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
export default function Page() {
const { state, setState } = useOnboarding(5);
const { state, updateState, setStep } = useOnboarding(
undefined,
"AGENT_CHOICE",
);
const [showInput, setShowInput] = useState(false);
const [agent, setAgent] = useState<GraphMeta | null>(null);
const [storeAgent, setStoreAgent] = useState<StoreAgentDetails | null>(null);
const [agent, setAgent] = useState<LibraryAgent | null>(null);
const router = useRouter();
const api = useBackendAPI();
useEffect(() => {
if (!state?.selectedAgentCreator || !state?.selectedAgentSlug) {
setStep(5);
}, [setStep]);
useEffect(() => {
if (!state?.selectedStoreListingVersionId) {
return;
}
api
.getStoreAgent(state?.selectedAgentCreator!, state?.selectedAgentSlug!)
.then((agent) => {
setStoreAgent(agent);
api
.addMarketplaceAgentToLibrary(agent?.store_listing_version_id!)
.then((agent) => {
setAgent(agent);
const update: { [key: string]: any } = {};
// Set default values from schema
Object.entries(agent?.input_schema?.properties || {}).forEach(
([key, value]) => {
// Skip if already set
if (state?.agentInput && state?.agentInput[key]) {
update[key] = state?.agentInput[key];
return;
}
update[key] = value.type !== "null" ? value.default || "" : "";
},
);
setState({
agentInput: update,
});
});
.getStoreAgentByVersionId(state?.selectedStoreListingVersionId)
.then((storeAgent) => {
setStoreAgent(storeAgent);
});
}, [
api,
setAgent,
setStoreAgent,
setState,
state?.selectedAgentCreator,
state?.selectedAgentSlug,
]);
api
.getAgentMetaByStoreListingVersionId(state?.selectedStoreListingVersionId)
.then((agent) => {
setAgent(agent);
const update: { [key: string]: any } = {};
// Set default values from schema
Object.entries(agent.input_schema.properties).forEach(
([key, value]) => {
// Skip if already set
if (state.agentInput && state.agentInput[key]) {
update[key] = state.agentInput[key];
return;
}
update[key] = value.type !== "null" ? value.default || "" : "";
},
);
updateState({
agentInput: update,
});
console.log("setting default input", update);
});
}, [api, setAgent, updateState, state?.selectedStoreListingVersionId]);
const setAgentInput = useCallback(
(key: string, value: string) => {
setState({
...state,
updateState({
agentInput: {
...state?.agentInput,
[key]: value,
},
});
},
[state, state?.agentInput, setState],
[state?.agentInput, updateState],
);
const runAgent = useCallback(() => {
if (!agent) {
return;
}
api.executeGraph(agent.agent_id, agent.agent_version, state?.agentInput);
console.log("running with", state?.agentInput);
api.addMarketplaceAgentToLibrary(
storeAgent?.store_listing_version_id || "",
);
api.executeGraph(agent.id, agent.version, state?.agentInput || {});
router.push("/onboarding/6-congrats");
}, [api, agent, router]);
}, [api, agent, router, state?.agentInput]);
const runYourAgent = (
<div className="ml-[54px] w-[481px] pl-5">
@@ -97,7 +101,13 @@ export default function Page() {
<div
onClick={() => {
setShowInput(true);
setState({ step: 6 });
setStep(6);
updateState({
completedSteps: [
...(state?.completedSteps || []),
"AGENT_NEW_RUN",
],
});
}}
className={cn(
"mt-16 flex h-[68px] w-[330px] items-center justify-center rounded-xl border-2 border-violet-700 bg-neutral-50",
@@ -196,7 +206,6 @@ export default function Page() {
key={key}
name={value.title!}
description={value.description || ""}
placeholder={value.placeholder || ""}
value={state?.agentInput?.[key] || ""}
onChange={(v) => setAgentInput(key, v)}
/>

View File

@@ -5,7 +5,10 @@ import { redirect } from "next/navigation";
export async function finishOnboarding() {
const api = new BackendAPI();
await api.updateUserOnboarding({ step: 5, isCompleted: true });
const onboarding = await api.getUserOnboarding();
await api.updateUserOnboarding({
completedSteps: [...onboarding.completedSteps, "CONGRATS"],
});
revalidatePath("/library", "layout");
redirect("/library");
}

View File

@@ -3,19 +3,21 @@ import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { finishOnboarding } from "./actions";
import confetti from "canvas-confetti";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
export default function Page() {
useOnboarding(7, "AGENT_INPUT");
const [showText, setShowText] = useState(false);
const [showSubtext, setShowSubtext] = useState(false);
useEffect(() => {
confetti({
particleCount: 100,
particleCount: 120,
spread: 360,
shapes: ["square", "circle"],
scalar: 2,
decay: 0.92,
origin: { y: 0.4, x: 0.5 },
decay: 0.93,
origin: { y: 0.38, x: 0.51 },
});
const timer0 = setTimeout(() => {
@@ -61,7 +63,7 @@ export default function Page() {
showSubtext ? "opacity-100" : "opacity-0",
)}
>
You earned 10$ for running your first agent
You earned 15$ for running your first agent
</p>
</div>
);

View File

@@ -1,102 +1,15 @@
"use client";
import { UserOnboarding } from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { usePathname, useRouter } from "next/navigation";
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useState,
} from "react";
const OnboardingContext = createContext<
| {
state: UserOnboarding | null;
setState: (state: Partial<UserOnboarding>) => void;
}
| undefined
>(undefined);
export function useOnboarding(step?: number) {
const context = useContext(OnboardingContext);
if (!context)
throw new Error("useOnboarding must be used within /onboarding pages");
useEffect(() => {
if (!step) return;
context.setState({ step });
}, [step]);
return context;
}
import { ReactNode } from "react";
export default function OnboardingLayout({
children,
}: {
children: ReactNode;
}) {
const [state, setStateRaw] = useState<UserOnboarding | null>(null);
const api = useBackendAPI();
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
const fetchOnboarding = async () => {
const onboarding = await api.getUserOnboarding();
setStateRaw(onboarding);
// Redirect outside onboarding if completed
if (onboarding.isCompleted && !pathname.startsWith("/onboarding/reset")) {
router.push("/library");
}
};
fetchOnboarding();
}, [api, pathname, router]);
const setState = useCallback(
(newState: Partial<UserOnboarding>) => {
function removeNullFields<T extends object>(obj: T): Partial<T> {
return Object.fromEntries(
Object.entries(obj).filter(([_, value]) => value != null),
) as Partial<T>;
}
const updateState = (state: Partial<UserOnboarding>) => {
if (!state) return;
api.updateUserOnboarding(state);
};
setStateRaw((prev) => {
if (newState.step && prev && prev?.step !== newState.step) {
updateState(removeNullFields({ ...prev, ...newState }));
}
if (!prev) {
// Handle initial state
return {
step: 1,
integrations: [],
isCompleted: false,
...newState,
};
}
return { ...prev, ...newState };
});
},
[api, setStateRaw],
);
return (
<OnboardingContext.Provider value={{ state, setState }}>
<div className="flex min-h-screen w-full items-center justify-center bg-gray-100">
<div className="mx-auto flex w-full flex-col items-center">
{children}
</div>
<div className="flex min-h-screen w-full items-center justify-center bg-gray-100">
<div className="mx-auto flex w-full flex-col items-center">
{children}
</div>
</OnboardingContext.Provider>
</div>
);
}

View File

@@ -3,20 +3,27 @@ import { redirect } from "next/navigation";
export default async function OnboardingPage() {
const api = new BackendAPI();
if (!api.isOnboardingEnabled()) {
redirect("/marketplace");
}
const onboarding = await api.getUserOnboarding();
switch (onboarding.step) {
case 1:
redirect("/onboarding/1-welcome");
case 2:
redirect("/onboarding/2-reason");
case 3:
redirect("/onboarding/3-services");
case 4:
redirect("/onboarding/4-agent");
case 5:
redirect("/onboarding/5-run");
default:
redirect("/onboarding/1-welcome");
}
// CONGRATS is the last step in intro onboarding
if (onboarding.completedSteps.includes("CONGRATS")) redirect("/marketplace");
else if (onboarding.completedSteps.includes("AGENT_INPUT"))
redirect("/onboarding/6-congrats");
else if (onboarding.completedSteps.includes("AGENT_NEW_RUN"))
redirect("/onboarding/5-run");
else if (onboarding.completedSteps.includes("AGENT_CHOICE"))
redirect("/onboarding/5-agent");
else if (onboarding.completedSteps.includes("INTEGRATIONS"))
redirect("/onboarding/4-agent");
else if (onboarding.completedSteps.includes("USAGE_REASON"))
redirect("/onboarding/3-services");
else if (onboarding.completedSteps.includes("WELCOME"))
redirect("/onboarding/2-reason");
redirect("/onboarding/1-welcome");
}

View File

@@ -4,14 +4,12 @@ import { redirect } from "next/navigation";
export default async function OnboardingResetPage() {
const api = new BackendAPI();
await api.updateUserOnboarding({
step: 1,
usageReason: undefined,
completedSteps: [],
usageReason: null,
integrations: [],
otherIntegrations: undefined,
selectedAgentCreator: undefined,
selectedAgentSlug: undefined,
agentInput: undefined,
isCompleted: false,
otherIntegrations: "",
selectedStoreListingVersionId: null,
agentInput: {},
});
redirect("/onboarding/1-welcome");
}

View File

@@ -7,6 +7,7 @@ import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
import { TooltipProvider } from "@/components/ui/tooltip";
import CredentialsProvider from "@/components/integrations/credentials-provider";
import { LaunchDarklyProvider } from "@/components/feature-flag/feature-flag-provider";
import OnboardingProvider from "@/components/onboarding/onboarding-provider";
export function Providers({ children, ...props }: ThemeProviderProps) {
return (
@@ -14,7 +15,9 @@ export function Providers({ children, ...props }: ThemeProviderProps) {
<BackendAPIProvider>
<CredentialsProvider>
<LaunchDarklyProvider>
<TooltipProvider>{children}</TooltipProvider>
<OnboardingProvider>
<TooltipProvider>{children}</TooltipProvider>
</OnboardingProvider>
</LaunchDarklyProvider>
</CredentialsProvider>
</BackendAPIProvider>

View File

@@ -5,6 +5,7 @@ import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { signupFormSchema } from "@/types/auth";
import BackendAPI from "@/lib/autogpt-server-api";
export async function signup(values: z.infer<typeof signupFormSchema>) {
"use server";
@@ -36,8 +37,13 @@ export async function signup(values: z.infer<typeof signupFormSchema>) {
if (data.session) {
await supabase.auth.setSession(data.session);
}
revalidatePath("/onboarding", "layout");
redirect("/onboarding");
// Don't onboard if disabled
if (await new BackendAPI().isOnboardingEnabled()) {
revalidatePath("/onboarding", "layout");
redirect("/onboarding");
}
revalidatePath("/", "layout");
redirect("/");
},
);
}

View File

@@ -4,7 +4,7 @@ interface OnboardingAgentInputProps {
className?: string;
name: string;
description: string;
placeholder: string;
placeholder?: string;
value: string;
onChange: (value: string) => void;
}

View File

@@ -3,7 +3,7 @@ import { ReactNode } from "react";
import OnboardingBackButton from "./OnboardingBackButton";
import { cn } from "@/lib/utils";
import OnboardingProgress from "./OnboardingProgress";
import { useOnboarding } from "@/app/onboarding/layout";
import { useOnboarding } from "./onboarding-provider";
export function OnboardingStep({
dotted,
@@ -33,7 +33,7 @@ export function OnboardingHeader({
transparent,
children,
}: OnboardingHeaderProps) {
const { state } = useOnboarding();
const { step } = useOnboarding();
return (
<div className="sticky top-0 z-10 w-full">
@@ -42,7 +42,7 @@ export function OnboardingHeader({
>
<div className="flex w-full items-center justify-between px-5 py-4">
<OnboardingBackButton href={backHref} />
<OnboardingProgress totalSteps={5} toStep={(state?.step || 1) - 1} />
<OnboardingProgress totalSteps={5} toStep={(step || 1) - 1} />
</div>
{children}
</div>

View File

@@ -0,0 +1,114 @@
"use client";
import { OnboardingStep, UserOnboarding } from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { usePathname, useRouter } from "next/navigation";
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useState,
} from "react";
const OnboardingContext = createContext<
| {
state: UserOnboarding | null;
updateState: (state: Partial<UserOnboarding>) => void;
step: number;
setStep: (step: number) => void;
}
| undefined
>(undefined);
export function useOnboarding(step?: number, completeStep?: OnboardingStep) {
const context = useContext(OnboardingContext);
if (!context)
throw new Error("useOnboarding must be used within an OnboardingProvider");
useEffect(() => {
if (
!completeStep ||
!context.state ||
context.state.completedSteps.includes(completeStep)
)
return;
context.updateState({
completedSteps: [...context.state.completedSteps, completeStep],
});
}, [completeStep, context.state, context.updateState]);
useEffect(() => {
if (step && context.step !== step) {
context.setStep(step);
}
}, [step, context.step, context.setStep]);
return context;
}
export default function OnboardingProvider({
children,
}: {
children: ReactNode;
}) {
const [state, setState] = useState<UserOnboarding | null>(null);
// Step is used to control the progress bar, it's frontend only
const [step, setStep] = useState(1);
const api = useBackendAPI();
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
const fetchOnboarding = async () => {
const enabled = await api.isOnboardingEnabled();
if (!enabled && pathname.startsWith("/onboarding")) {
router.push("/marketplace");
return;
}
const onboarding = await api.getUserOnboarding();
setState((prev) => ({ ...onboarding, ...prev }));
// Redirect outside onboarding if completed
// If user did CONGRATS step, that means they completed introductory onboarding
if (
onboarding.completedSteps.includes("CONGRATS") &&
pathname.startsWith("/onboarding") &&
!pathname.startsWith("/onboarding/reset")
) {
router.push("/marketplace");
}
};
fetchOnboarding();
}, [api, pathname, router]);
const updateState = useCallback(
(newState: Partial<UserOnboarding>) => {
setState((prev) => {
api.updateUserOnboarding({ ...prev, ...newState });
if (!prev) {
// Handle initial state
return {
completedSteps: [],
usageReason: null,
integrations: [],
otherIntegrations: null,
selectedStoreListingVersionId: null,
agentInput: null,
...newState,
};
}
return { ...prev, ...newState };
});
},
[api, setState],
);
return (
<OnboardingContext.Provider value={{ state, updateState, step, setStep }}>
{children}
</OnboardingContext.Provider>
);
}

View File

@@ -186,6 +186,11 @@ export default class BackendAPI {
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 /////////////////////
////////////////////////////////////////
@@ -430,6 +435,18 @@ export default class BackendAPI {
);
}
getAgentMetaByStoreListingVersionId(
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;

View File

@@ -761,15 +761,22 @@ export interface RefundRequest {
updated_at: Date;
}
export type OnboardingStep =
| "WELCOME"
| "USAGE_REASON"
| "INTEGRATIONS"
| "AGENT_CHOICE"
| "AGENT_NEW_RUN"
| "AGENT_INPUT"
| "CONGRATS";
export interface UserOnboarding {
step: number;
usageReason?: string;
completedSteps: OnboardingStep[];
usageReason: string | null;
integrations: string[];
otherIntegrations?: string;
selectedAgentCreator?: string;
selectedAgentSlug?: string;
agentInput?: { [key: string]: string };
isCompleted: boolean;
otherIntegrations: string | null;
selectedStoreListingVersionId: string | null;
agentInput: { [key: string]: string } | null;
}
/* *** UTILITIES *** */

View File

@@ -390,3 +390,8 @@ export function getValue(key: string, value: any) {
return acc[k.key];
}, value);
}
/** Check if a string is empty or whitespace */
export function isEmptyOrWhitespace(str: string | undefined | null): boolean {
return !str || str.trim().length === 0;
}

View File

@@ -46,11 +46,7 @@ export class LoginPage {
console.log("Waiting for navigation"); // Debug log
await navigationPromise;
// If the user is redirected to onboarding, manually redirect to /marketplace
if (this.page.url().includes("/onboarding")) {
console.log("Redirecting to /marketplace"); // Debug log
await this.page.goto("/marketplace");
}
await this.page.goto("/marketplace");
console.log("Navigation complete, waiting for network idle"); // Debug log
await this.page.waitForLoadState("load", { timeout: 10_000 });