diff --git a/autogpt_platform/backend/backend/data/onboarding.py b/autogpt_platform/backend/backend/data/onboarding.py new file mode 100644 index 0000000000..174bf37803 --- /dev/null +++ b/autogpt_platform/backend/backend/data/onboarding.py @@ -0,0 +1,247 @@ +import re +from typing import Any, Optional + +import prisma +import pydantic +from prisma import Json +from prisma.models import ( + AgentGraph, + AgentGraphExecution, + StoreListingVersion, + 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.server.v2.store.model import StoreAgentDetails + +# Mapping from user reason id to categories to search for when choosing agent to show +REASON_MAPPING: dict[str, list[str]] = { + "content_marketing": ["writing", "marketing", "creative"], + "business_workflow_automation": ["business", "productivity"], + "data_research": ["data", "research"], + "ai_innovation": ["development", "research"], + "personal_productivity": ["personal", "productivity"], +} + + +class UserOnboardingUpdate(pydantic.BaseModel): + step: int + usageReason: Optional[str] = None + integrations: list[str] = pydantic.Field(default_factory=list) + otherIntegrations: Optional[str] = None + selectedAgentCreator: Optional[str] = None + selectedAgentSlug: Optional[str] = None + agentInput: Optional[dict[str, Any]] = None + isCompleted: bool = False + + +async def get_user_onboarding(user_id: str): + return await UserOnboarding.prisma().upsert( + where={"userId": user_id}, + data={ + "create": {"userId": user_id}, # type: ignore + "update": {}, + }, + ) + + +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["usageReason"] = data.usageReason + if data.integrations: + update["integrations"] = data.integrations + if data.otherIntegrations: + 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: + update["agentInput"] = Json(data.agentInput) + + return await UserOnboarding.prisma().upsert( + where={"userId": user_id}, + data={ + "create": {"userId": user_id, **update}, # type: ignore + "update": update, + }, + ) + + +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. + + Args: + text (str): The input string. + + Returns: + list[str]: A list of cleaned words. + """ + # Remove all special characters (keep only alphanumeric and whitespace) + cleaned_text = re.sub(r"[^a-zA-Z0-9\s,]", "", text.strip()[:100]) + + # Split by whitespace and commas + words = re.split(r"[\s,]+", cleaned_text) + + # Remove empty strings from the list + words = [word.lower() for word in words if word] + + return words + + +def calculate_points( + agent, categories: list[str], custom: list[str], integrations: list[str] +) -> int: + """ + Calculates the total points for an agent based on the specified criteria. + + Args: + agent: The agent object. + categories (list[str]): List of categories to match. + words (list[str]): List of words to match in the description. + + Returns: + int: Total points for the agent. + """ + points = 0 + + # 1. Category Matches + matched_categories = sum( + 1 for category in categories if category in agent.categories + ) + points += matched_categories * 100 + + # 2. Description Word Matches + description_words = agent.description.split() # Split description into words + matched_words = sum(1 for word in custom if word in description_words) + points += matched_words * 100 + + matched_words = sum(1 for word in integrations if word in description_words) + points += matched_words * 50 + + # 3. Featured Bonus + if agent.featured: + points += 50 + + # 4. Rating Bonus + points += agent.rating * 10 + + # 5. Runs Bonus + runs_points = min(agent.runs / 1000 * 100, 100) # Cap at 100 points + points += runs_points + + return int(points) + + +async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]: + user_onboarding = await get_user_onboarding(user_id) + categories = REASON_MAPPING.get(user_onboarding.usageReason or "", []) + + where_clause: dict[str, Any] = {} + + custom = clean_and_split((user_onboarding.usageReason or "").lower()) + + if categories: + where_clause["OR"] = [ + {"categories": {"has": category}} for category in categories + ] + else: + where_clause["OR"] = [ + {"description": {"contains": word, "mode": "insensitive"}} + for word in custom + ] + + where_clause["OR"] += [ + {"description": {"contains": word, "mode": "insensitive"}} + for word in user_onboarding.integrations + ] + + agents = await prisma.models.StoreAgent.prisma().find_many( + where=prisma.types.StoreAgentWhereInput(**where_clause), + order=[ + {"featured": "desc"}, + {"runs": "desc"}, + {"rating": "desc"}, + ], + ) + + if len(agents) < 2: + agents += await prisma.models.StoreAgent.prisma().find_many( + where={ + "listing_id": {"not_in": [agent.listing_id for agent in agents]}, + }, + order=[ + {"featured": "desc"}, + {"runs": "desc"}, + {"rating": "desc"}, + ], + take=2 - len(agents), + ) + + # Calculate points for the first 30 agents and choose the top 2 + agent_points = [] + for agent in agents[:50]: + points = calculate_points( + agent, categories, custom, user_onboarding.integrations + ) + agent_points.append((agent, points)) + + agent_points.sort(key=lambda x: x[1], reverse=True) + recommended_agents = [agent for agent, _ in agent_points[:2]] + + return [ + 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, + ) + for agent in recommended_agents + ] diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index 0863e651ba..e72735ac94 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -43,6 +43,12 @@ from backend.data.credit import ( set_auto_top_up, ) from backend.data.notifications import NotificationPreference, NotificationPreferenceDTO +from backend.data.onboarding import ( + UserOnboardingUpdate, + get_recommended_agents, + get_user_onboarding, + update_user_onboarding, +) from backend.data.user import ( get_or_create_user, get_user_notification_preference, @@ -152,6 +158,38 @@ async def update_preferences( return output +######################################################## +##################### Onboarding ####################### +######################################################## + + +@v1_router.get( + "/onboarding", tags=["onboarding"], dependencies=[Depends(auth_middleware)] +) +async def get_onboarding(user_id: Annotated[str, Depends(get_user_id)]): + return await get_user_onboarding(user_id) + + +@v1_router.patch( + "/onboarding", tags=["onboarding"], dependencies=[Depends(auth_middleware)] +) +async def update_onboarding( + user_id: Annotated[str, Depends(get_user_id)], data: UserOnboardingUpdate +): + return await update_user_onboarding(user_id, data) + + +@v1_router.get( + "/onboarding/agents", + tags=["onboarding"], + dependencies=[Depends(auth_middleware)], +) +async def get_onboarding_agents( + user_id: Annotated[str, Depends(get_user_id)], +): + return await get_recommended_agents(user_id) + + ######################################################## ##################### Blocks ########################### ######################################################## diff --git a/autogpt_platform/backend/backend/server/v2/library/db.py b/autogpt_platform/backend/backend/server/v2/library/db.py index 59ca701914..1d0ccd0559 100644 --- a/autogpt_platform/backend/backend/server/v2/library/db.py +++ b/autogpt_platform/backend/backend/server/v2/library/db.py @@ -14,6 +14,7 @@ import backend.server.v2.library.model as library_model import backend.server.v2.store.exceptions as store_exceptions import backend.server.v2.store.image_gen as store_image_gen import backend.server.v2.store.media as store_media +from backend.data.db import locked_transaction from backend.data.includes import library_agent_include from backend.util.settings import Config @@ -362,56 +363,70 @@ async def add_store_agent_to_library( ) try: - store_listing_version = ( - await prisma.models.StoreListingVersion.prisma().find_unique( - where={"id": store_listing_version_id}, include={"Agent": True} - ) - ) - if not store_listing_version or not store_listing_version.Agent: - logger.warning( - f"Store listing version not found: {store_listing_version_id}" - ) - raise store_exceptions.AgentNotFoundError( - f"Store listing version {store_listing_version_id} not found or invalid" + async with locked_transaction(f"user_trx_{user_id}"): + store_listing_version = ( + await prisma.models.StoreListingVersion.prisma().find_unique( + where={"id": store_listing_version_id}, include={"Agent": True} + ) ) + if not store_listing_version or not store_listing_version.Agent: + logger.warning( + f"Store listing version not found: {store_listing_version_id}" + ) + raise store_exceptions.AgentNotFoundError( + f"Store listing version {store_listing_version_id} not found or invalid" + ) - graph = store_listing_version.Agent - if graph.userId == user_id: - logger.warning( - f"User #{user_id} attempted to add their own agent to their library" - ) - raise store_exceptions.DatabaseError("Cannot add own agent to library") + graph = store_listing_version.Agent + if graph.userId == user_id: + logger.warning( + f"User #{user_id} attempted to add their own agent to their library" + ) + raise store_exceptions.DatabaseError("Cannot add own agent to library") - # Check if user already has this agent - existing_library_agent = await prisma.models.LibraryAgent.prisma().find_first( - where={ - "userId": user_id, - "agentId": graph.id, - "agentVersion": graph.version, - }, - include=library_agent_include(user_id), - ) - if existing_library_agent: - logger.debug( - f"User #{user_id} already has graph #{graph.id} in their library" - ) - return library_model.LibraryAgent.from_db(existing_library_agent) - - # Create LibraryAgent entry - added_agent = await prisma.models.LibraryAgent.prisma().create( - data={ - "userId": user_id, - "Agent": { - "connect": { - "graphVersionId": {"id": graph.id, "version": graph.version} + # Check if user already has this agent + existing_library_agent = ( + await prisma.models.LibraryAgent.prisma().find_first( + where={ + "userId": user_id, + "agentId": graph.id, + "agentVersion": graph.version, }, + include=library_agent_include(user_id), + ) + ) + if existing_library_agent: + if existing_library_agent.isDeleted: + # Even if agent exists it needs to be marked as not deleted + await set_is_deleted_for_library_agent( + user_id, graph.id, graph.version, False + ) + else: + logger.debug( + f"User #{user_id} already has graph #{graph.id} " + "in their library" + ) + return library_model.LibraryAgent.from_db(existing_library_agent) + + # Create LibraryAgent entry + added_agent = await prisma.models.LibraryAgent.prisma().create( + data={ + "userId": user_id, + "Agent": { + "connect": { + "graphVersionId": {"id": graph.id, "version": graph.version} + }, + }, + "isCreatedByUser": False, }, - "isCreatedByUser": False, - }, - include=library_agent_include(user_id), - ) - logger.debug(f"Added agent #{graph.id} to library for user #{user_id}") - return library_model.LibraryAgent.from_db(added_agent) + include=library_agent_include(user_id), + ) + logger.debug( + f"Added graph #{graph.id} " + f"for store listing #{store_listing_version.id} " + f"to library for user #{user_id}" + ) + return library_model.LibraryAgent.from_db(added_agent) except store_exceptions.AgentNotFoundError: # Reraise for external handling. @@ -421,6 +436,45 @@ async def add_store_agent_to_library( raise store_exceptions.DatabaseError("Failed to add agent to library") from e +async def set_is_deleted_for_library_agent( + user_id: str, agent_id: str, agent_version: int, is_deleted: bool +) -> None: + """ + Changes the isDeleted flag for a library agent. + + Args: + user_id: The user's library from which the agent is being removed. + agent_id: The ID of the agent to remove. + agent_version: The version of the agent to remove. + is_deleted: Whether the agent is being marked as deleted. + + Raises: + DatabaseError: If there's an issue updating the Library + """ + logger.debug( + f"Setting isDeleted={is_deleted} for agent {agent_id} v{agent_version} " + f"in library for user {user_id}" + ) + try: + logger.warning( + f"Setting isDeleted={is_deleted} for agent {agent_id} v{agent_version} in library for user {user_id}" + ) + count = await prisma.models.LibraryAgent.prisma().update_many( + where={ + "userId": user_id, + "agentId": agent_id, + "agentVersion": agent_version, + }, + data={"isDeleted": is_deleted}, + ) + logger.warning(f"Updated {count} isDeleted library agents") + except prisma.errors.PrismaError as e: + logger.error(f"Database error setting agent isDeleted: {e}") + raise store_exceptions.DatabaseError( + "Failed to set agent isDeleted in library" + ) from e + + ############################################## ########### Presets DB Functions ############# ############################################## diff --git a/autogpt_platform/backend/backend/server/v2/library/routes/agents.py b/autogpt_platform/backend/backend/server/v2/library/routes/agents.py index 1570219298..2db5024388 100644 --- a/autogpt_platform/backend/backend/server/v2/library/routes/agents.py +++ b/autogpt_platform/backend/backend/server/v2/library/routes/agents.py @@ -106,7 +106,7 @@ async def add_marketplace_agent_to_library( user_id: ID of the authenticated user. Returns: - 201 (Created) on success. + library_model.LibraryAgent: Agent added to the library Raises: HTTPException(404): If the listing version is not found. diff --git a/autogpt_platform/backend/migrations/20250223110000_add_onboarding_model/migration.sql b/autogpt_platform/backend/migrations/20250223110000_add_onboarding_model/migration.sql new file mode 100644 index 0000000000..3b14921639 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250223110000_add_onboarding_model/migration.sql @@ -0,0 +1,26 @@ +-- Create UserOnboarding table +CREATE TABLE "UserOnboarding" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "step" INTEGER NOT NULL DEFAULT 0, + "usageReason" TEXT, + "integrations" TEXT[] DEFAULT ARRAY[]::TEXT[], + "otherIntegrations" TEXT, + "selectedAgentCreator" TEXT, + "selectedAgentSlug" TEXT, + "agentInput" JSONB, + "isCompleted" BOOLEAN NOT NULL DEFAULT false, + "userId" TEXT NOT NULL, + + CONSTRAINT "UserOnboarding_pkey" PRIMARY KEY ("id") +); + +-- Create unique constraint on userId +ALTER TABLE "UserOnboarding" ADD CONSTRAINT "UserOnboarding_userId_key" UNIQUE ("userId"); + +-- Create index on userId +CREATE INDEX "UserOnboarding_userId_idx" ON "UserOnboarding"("userId"); + +-- Add foreign key constraint +ALTER TABLE "UserOnboarding" ADD CONSTRAINT "UserOnboarding_userId_fkey" +FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index 12196be673..6c8a3ef048 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -46,6 +46,7 @@ model User { LibraryAgent LibraryAgent[] Profile Profile[] + UserOnboarding UserOnboarding? StoreListing StoreListing[] StoreListingReview StoreListingReview[] StoreListingSubmission StoreListingSubmission[] @@ -57,6 +58,25 @@ model User { @@index([email]) } +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) + + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + // This model describes the Agent Graph/Flow (Multi Agent System). model AgentGraph { id String @default(uuid()) diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index 621bd53ec6..a8765aa8ce 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -51,6 +51,7 @@ "@xyflow/react": "^12.4.2", "ajv": "^8.17.1", "boring-avatars": "^1.11.2", + "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", @@ -94,7 +95,8 @@ "@storybook/nextjs": "^8.5.3", "@storybook/react": "^8.3.5", "@storybook/test": "^8.3.5", - "@storybook/test-runner": "^0.20.1", + "@storybook/test-runner": "^0.21.0", + "@types/canvas-confetti": "^1.9.0", "@types/lodash": "^4.17.13", "@types/negotiator": "^0.6.3", "@types/node": "^22.13.0", diff --git a/autogpt_platform/frontend/src/app/login/actions.ts b/autogpt_platform/frontend/src/app/login/actions.ts index ba696c094f..d6ee3c744c 100644 --- a/autogpt_platform/frontend/src/app/login/actions.ts +++ b/autogpt_platform/frontend/src/app/login/actions.ts @@ -49,11 +49,14 @@ export async function login(values: z.infer) { } await api.createUser(); + if (!(await api.getUserOnboarding()).isCompleted) { + revalidatePath("/onboarding", "layout"); + redirect("/onboarding"); + } if (data.session) { await supabase.auth.setSession(data.session); } - console.log("Logged in"); revalidatePath("/", "layout"); redirect("/"); }); @@ -86,7 +89,10 @@ export async function providerLogin(provider: LoginProvider) { } await api.createUser(); - console.log("Logged in"); + if (!(await api.getUserOnboarding()).isCompleted) { + revalidatePath("/onboarding", "layout"); + redirect("/onboarding"); + } }, ); } diff --git a/autogpt_platform/frontend/src/app/onboarding/1-welcome/page.tsx b/autogpt_platform/frontend/src/app/onboarding/1-welcome/page.tsx index 8dbccba2be..d976e9b66c 100644 --- a/autogpt_platform/frontend/src/app/onboarding/1-welcome/page.tsx +++ b/autogpt_platform/frontend/src/app/onboarding/1-welcome/page.tsx @@ -5,7 +5,8 @@ import OnboardingButton from "@/components/onboarding/OnboardingButton"; import Image from "next/image"; export default function Page() { - const {} = useOnboarding(1); + // Just set step to 1 + useOnboarding(1); return ( <> diff --git a/autogpt_platform/frontend/src/app/onboarding/2-reason/page.tsx b/autogpt_platform/frontend/src/app/onboarding/2-reason/page.tsx index a4c25a966d..b66d979636 100644 --- a/autogpt_platform/frontend/src/app/onboarding/2-reason/page.tsx +++ b/autogpt_platform/frontend/src/app/onboarding/2-reason/page.tsx @@ -56,13 +56,13 @@ export default function Page() { setState({ usageReason })} /> Next diff --git a/autogpt_platform/frontend/src/app/onboarding/3-services/page.tsx b/autogpt_platform/frontend/src/app/onboarding/3-services/page.tsx index bfccf4c901..23c0ae789e 100644 --- a/autogpt_platform/frontend/src/app/onboarding/3-services/page.tsx +++ b/autogpt_platform/frontend/src/app/onboarding/3-services/page.tsx @@ -118,13 +118,17 @@ export default function Page() { const switchIntegration = useCallback( (name: string) => { + if (!state) { + return; + } + const integrations = state.integrations.includes(name) ? state.integrations.filter((i) => i !== name) : [...state.integrations, name]; setState({ integrations }); }, - [state.integrations, setState], + [state, setState], ); return ( @@ -144,7 +148,7 @@ export default function Page() { @@ -156,7 +160,7 @@ export default function Page() { setState({ otherIntegrations })} /> @@ -166,7 +170,7 @@ export default function Page() { className="mb-2" href="/onboarding/4-agent" disabled={ - state.integrations.length === 0 && + state?.integrations.length === 0 && isEmptyOrWhitespace(state.otherIntegrations) } > diff --git a/autogpt_platform/frontend/src/app/onboarding/4-agent/page.tsx b/autogpt_platform/frontend/src/app/onboarding/4-agent/page.tsx index 1087904443..2f25463933 100644 --- a/autogpt_platform/frontend/src/app/onboarding/4-agent/page.tsx +++ b/autogpt_platform/frontend/src/app/onboarding/4-agent/page.tsx @@ -8,29 +8,10 @@ import { } from "@/components/onboarding/OnboardingStep"; import { OnboardingText } from "@/components/onboarding/OnboardingText"; import OnboardingAgentCard from "@/components/onboarding/OnboardingAgentCard"; - -const agents = [ - { - id: "0", - image: "/placeholder.png", - name: "Viral News Video Creator: AI TikTok Shorts", - description: - "Description of what the agent does. Written by the creator. Example of text that's longer than two lines. Lorem ipsum set dolor amet bacon ipsum dolor amet kielbasa chicken ullamco frankfurter cupim nisi. Esse jerky turkey pancetta lorem officia ad qui ut ham hock venison ut pig mollit ball tip. Tempor chicken eiusmod tongue tail pork belly labore kielbasa consequat culpa cow aliqua. Ea tail dolore sausage flank.", - author: "Pwuts", - runs: 1539, - rating: 4.1, - }, - { - id: "1", - image: "/placeholder.png", - name: "Financial Analysis Agent: Your Personalized Financial Insights Tool", - description: - "Description of what the agent does. Written by the creator. Example of text that's longer than two lines. Lorem ipsum set dolor amet bacon ipsum dolor amet kielbasa chicken ullamco frankfurter cupim nisi. Esse jerky turkey pancetta lorem officia ad qui ut ham hock venison ut pig mollit ball tip. Tempor chicken eiusmod tongue tail pork belly labore kielbasa consequat culpa cow aliqua. Ea tail dolore sausage flank.", - author: "John Ababseh", - runs: 824, - rating: 4.5, - }, -]; +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; @@ -38,6 +19,31 @@ function isEmptyOrWhitespace(str: string | undefined | null): boolean { export default function Page() { const { state, setState } = useOnboarding(4); + const [agents, setAgents] = useState([]); + const api = useBackendAPI(); + + useEffect(() => { + api.getOnboardingAgents().then((agents) => { + if (agents.length < 2) { + finishOnboarding(); + } + setAgents(agents); + }); + }, [api, setAgents]); + + useEffect(() => { + // Deselect agent if it's not in the list of agents + if ( + state?.selectedAgentSlug && + !agents.some((agent) => agent.slug === state.selectedAgentSlug) + ) { + setState({ + selectedAgentSlug: undefined, + selectedAgentCreator: undefined, + agentInput: undefined, + }); + } + }, [state?.selectedAgentSlug, setState, agents]); return ( @@ -52,21 +58,39 @@ export default function Page() {
setState({ chosenAgentId: "0" })} + {...(agents[0] || {})} + selected={ + agents[0] !== undefined + ? state?.selectedAgentSlug == agents[0]?.slug + : false + } + onClick={() => + setState({ + selectedAgentSlug: agents[0].slug, + selectedAgentCreator: agents[0].creator, + }) + } /> setState({ chosenAgentId: "1" })} + {...(agents[1] || {})} + selected={ + agents[1] !== undefined + ? state?.selectedAgentSlug == agents[1]?.slug + : false + } + onClick={() => + setState({ + selectedAgentSlug: agents[1].slug, + selectedAgentCreator: agents[1].creator, + }) + } />
Next diff --git a/autogpt_platform/frontend/src/app/onboarding/5-run/page.tsx b/autogpt_platform/frontend/src/app/onboarding/5-run/page.tsx index 66597c499e..a8df78f2fa 100644 --- a/autogpt_platform/frontend/src/app/onboarding/5-run/page.tsx +++ b/autogpt_platform/frontend/src/app/onboarding/5-run/page.tsx @@ -9,57 +9,80 @@ import { useOnboarding } from "../layout"; import StarRating from "@/components/onboarding/StarRating"; import { Play } from "lucide-react"; import { cn } from "@/lib/utils"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import OnboardingAgentInput from "@/components/onboarding/OnboardingAgentInput"; import Image from "next/image"; - -const agents = [ - { - id: "0", - image: "/placeholder.png", - name: "Viral News Video Creator: AI TikTok Shorts", - description: - "Description of what the agent does. Written by the creator. Example of text that's longer than two lines. Lorem ipsum set dolor amet bacon ipsum dolor amet kielbasa chicken ullamco frankfurter cupim nisi. Esse jerky turkey pancetta lorem officia ad qui ut ham hock venison ut pig mollit ball tip. Tempor chicken eiusmod tongue tail pork belly labore kielbasa consequat culpa cow aliqua. Ea tail dolore sausage flank.", - author: "Pwuts", - runs: 1539, - rating: 4.1, - }, - { - id: "1", - image: "/placeholder.png", - name: "Financial Analysis Agent: Your Personalized Financial Insights Tool", - description: - "Description of what the agent does. Written by the creator. Example of text that's longer than two lines. Lorem ipsum set dolor amet bacon ipsum dolor amet kielbasa chicken ullamco frankfurter cupim nisi. Esse jerky turkey pancetta lorem officia ad qui ut ham hock venison ut pig mollit ball tip. Tempor chicken eiusmod tongue tail pork belly labore kielbasa consequat culpa cow aliqua. Ea tail dolore sausage flank.", - author: "John Ababseh", - runs: 824, - rating: 4.5, - }, -]; - -function isEmptyOrWhitespace(str: string | undefined | null): boolean { - return !str || str.trim().length === 0; -} +import { LibraryAgent, StoreAgentDetails } from "@/lib/autogpt-server-api"; +import { useBackendAPI } from "@/lib/autogpt-server-api/context"; +import { useRouter } from "next/navigation"; export default function Page() { const { state, setState } = useOnboarding(5); const [showInput, setShowInput] = useState(false); - const selectedAgent = agents.find( - (agent) => agent.id === state.chosenAgentId, - ); + const [storeAgent, setStoreAgent] = useState(null); + const [agent, setAgent] = useState(null); + const router = useRouter(); + const api = useBackendAPI(); + + useEffect(() => { + if (!state?.selectedAgentCreator || !state?.selectedAgentSlug) { + 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, + }); + }); + }); + }, [ + api, + setAgent, + setStoreAgent, + setState, + state?.selectedAgentCreator, + state?.selectedAgentSlug, + ]); const setAgentInput = useCallback( (key: string, value: string) => { setState({ ...state, agentInput: { - ...state.agentInput, + ...state?.agentInput, [key]: value, }, }); }, - [state, setState], + [state, state?.agentInput, setState], ); + const runAgent = useCallback(() => { + if (!agent) { + return; + } + api.executeGraph(agent.agent_id, agent.agent_version, state?.agentInput); + router.push("/onboarding/6-congrats"); + }, [api, agent, router]); + const runYourAgent = (
@@ -115,11 +138,10 @@ export default function Page() { SELECTED AGENT -
{/* Left image */} Description - {selectedAgent?.name} + {agent?.name} - by {selectedAgent?.author} + by {storeAgent?.creator}
- {selectedAgent?.runs.toLocaleString("en-US")} runs + {storeAgent?.runs.toLocaleString("en-US")} runs
- {/* Right side */} {!showInput ? ( runYourAgent @@ -169,28 +190,28 @@ export default function Page() { Input - setAgentInput("videoCount", v)} - /> - setAgentInput("sourceWebsite", v)} - /> + {Object.entries(agent?.input_schema?.properties || {}).map( + ([key, value]) => ( + setAgentInput(key, v)} + /> + ), + )} value.trim() === "", + ) || !agent } + onClick={runAgent} > Run agent diff --git a/autogpt_platform/frontend/src/app/onboarding/6-congrats/actions.ts b/autogpt_platform/frontend/src/app/onboarding/6-congrats/actions.ts new file mode 100644 index 0000000000..7666844e42 --- /dev/null +++ b/autogpt_platform/frontend/src/app/onboarding/6-congrats/actions.ts @@ -0,0 +1,11 @@ +"use server"; +import BackendAPI from "@/lib/autogpt-server-api"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +export async function finishOnboarding() { + const api = new BackendAPI(); + await api.updateUserOnboarding({ step: 5, isCompleted: true }); + revalidatePath("/library", "layout"); + redirect("/library"); +} diff --git a/autogpt_platform/frontend/src/app/onboarding/6-congrats/page.tsx b/autogpt_platform/frontend/src/app/onboarding/6-congrats/page.tsx new file mode 100644 index 0000000000..3c121dccb5 --- /dev/null +++ b/autogpt_platform/frontend/src/app/onboarding/6-congrats/page.tsx @@ -0,0 +1,68 @@ +"use client"; +import { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; +import { finishOnboarding } from "./actions"; +import confetti from "canvas-confetti"; + +export default function Page() { + const [showText, setShowText] = useState(false); + const [showSubtext, setShowSubtext] = useState(false); + + useEffect(() => { + confetti({ + particleCount: 100, + spread: 360, + shapes: ["square", "circle"], + scalar: 2, + decay: 0.92, + origin: { y: 0.4, x: 0.5 }, + }); + + const timer0 = setTimeout(() => { + setShowText(true); + }, 100); + + const timer1 = setTimeout(() => { + setShowSubtext(true); + }, 500); + + const timer2 = setTimeout(() => { + finishOnboarding(); + }, 3000); + + return () => { + clearTimeout(timer0); + clearTimeout(timer1); + clearTimeout(timer2); + }; + }, []); + + return ( +
+
+ 🎉 +
+

+ Congrats! +

+

+ You earned 10$ for running your first agent +

+
+ ); +} diff --git a/autogpt_platform/frontend/src/app/onboarding/layout.tsx b/autogpt_platform/frontend/src/app/onboarding/layout.tsx index b80d7c8bf2..9643a35328 100644 --- a/autogpt_platform/frontend/src/app/onboarding/layout.tsx +++ b/autogpt_platform/frontend/src/app/onboarding/layout.tsx @@ -1,25 +1,20 @@ "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"; -type OnboardingState = { - step: number; - usageReason?: string; - integrations: string[]; - otherIntegrations?: string; - chosenAgentId?: string; - agentInput?: { [key: string]: string }; -}; - const OnboardingContext = createContext< | { - state: OnboardingState; - setState: (state: Partial) => void; + state: UserOnboarding | null; + setState: (state: Partial) => void; } | undefined >(undefined); @@ -27,7 +22,7 @@ const OnboardingContext = createContext< export function useOnboarding(step?: number) { const context = useContext(OnboardingContext); if (!context) - throw new Error("useOnboarding must be used within OnboardingLayout"); + throw new Error("useOnboarding must be used within /onboarding pages"); useEffect(() => { if (!step) return; @@ -43,14 +38,57 @@ export default function OnboardingLayout({ }: { children: ReactNode; }) { - const [state, setStateRaw] = useState({ - step: 0, - integrations: [], - }); + const [state, setStateRaw] = useState(null); + const api = useBackendAPI(); + const pathname = usePathname(); + const router = useRouter(); - const setState = (newState: Partial) => { - setStateRaw((prev) => ({ ...prev, ...newState })); - }; + 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) => { + function removeNullFields(obj: T): Partial { + return Object.fromEntries( + Object.entries(obj).filter(([_, value]) => value != null), + ) as Partial; + } + + const updateState = (state: Partial) => { + 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 ( diff --git a/autogpt_platform/frontend/src/app/onboarding/page.tsx b/autogpt_platform/frontend/src/app/onboarding/page.tsx index 0fc906813a..a4b6db8b81 100644 --- a/autogpt_platform/frontend/src/app/onboarding/page.tsx +++ b/autogpt_platform/frontend/src/app/onboarding/page.tsx @@ -1,5 +1,22 @@ +import BackendAPI from "@/lib/autogpt-server-api"; import { redirect } from "next/navigation"; -export default function OnboardingPage() { - redirect("/onboarding/1-welcome"); +export default async function OnboardingPage() { + const api = new BackendAPI(); + 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"); + } } diff --git a/autogpt_platform/frontend/src/app/onboarding/reset/page.ts b/autogpt_platform/frontend/src/app/onboarding/reset/page.ts new file mode 100644 index 0000000000..a68e817242 --- /dev/null +++ b/autogpt_platform/frontend/src/app/onboarding/reset/page.ts @@ -0,0 +1,17 @@ +import BackendAPI from "@/lib/autogpt-server-api"; +import { redirect } from "next/navigation"; + +export default async function OnboardingResetPage() { + const api = new BackendAPI(); + await api.updateUserOnboarding({ + step: 1, + usageReason: undefined, + integrations: [], + otherIntegrations: undefined, + selectedAgentCreator: undefined, + selectedAgentSlug: undefined, + agentInput: undefined, + isCompleted: false, + }); + redirect("/onboarding/1-welcome"); +} diff --git a/autogpt_platform/frontend/src/app/signup/actions.ts b/autogpt_platform/frontend/src/app/signup/actions.ts index 4f4db22d56..acd9b0ff40 100644 --- a/autogpt_platform/frontend/src/app/signup/actions.ts +++ b/autogpt_platform/frontend/src/app/signup/actions.ts @@ -36,8 +36,8 @@ export async function signup(values: z.infer) { if (data.session) { await supabase.auth.setSession(data.session); } - revalidatePath("/", "layout"); - redirect("/"); + revalidatePath("/onboarding", "layout"); + redirect("/onboarding"); }, ); } diff --git a/autogpt_platform/frontend/src/components/onboarding/OnboardingAgentCard.tsx b/autogpt_platform/frontend/src/components/onboarding/OnboardingAgentCard.tsx index a345c5f475..92d8b988a4 100644 --- a/autogpt_platform/frontend/src/components/onboarding/OnboardingAgentCard.tsx +++ b/autogpt_platform/frontend/src/components/onboarding/OnboardingAgentCard.tsx @@ -1,25 +1,19 @@ import { cn } from "@/lib/utils"; import Image from "next/image"; import StarRating from "./StarRating"; +import { StoreAgentDetails } from "@/lib/autogpt-server-api"; -interface OnboardingAgentCardProps { - id: string; - image: string; - name: string; - description: string; - author: string; - runs: number; - rating: number; +type OnboardingAgentCardProps = StoreAgentDetails & { selected?: boolean; onClick: () => void; -} +}; export default function OnboardingAgentCard({ - id, - image, - name, + agent_image, + creator_avatar, + agent_name, description, - author, + creator, runs, rating, selected, @@ -29,7 +23,7 @@ export default function OnboardingAgentCard({
Agent cover {/* Profile picture overlay */}
Profile picture {/* Title - 2 lines max */}

- {name} + {agent_name}

{/* Author - single line with truncate */}

- by {author} + by {creator}

{/* Description - 3 lines max */} @@ -83,14 +77,14 @@ export default function OnboardingAgentCard({ {/* Bottom stats */}
- {runs.toLocaleString("en-US")} runs + {runs?.toLocaleString("en-US")} runs
diff --git a/autogpt_platform/frontend/src/components/onboarding/OnboardingInput.tsx b/autogpt_platform/frontend/src/components/onboarding/OnboardingInput.tsx index 100e5af043..bf754e09fe 100644 --- a/autogpt_platform/frontend/src/components/onboarding/OnboardingInput.tsx +++ b/autogpt_platform/frontend/src/components/onboarding/OnboardingInput.tsx @@ -17,9 +17,8 @@ export default function OnboardingInput({ void; }; -type OnboardingListProps = { - className?: string; - elements: Array<{ - label: string; - text: string; - id: string; - }>; - selectedId?: string; - onSelect: (id: string) => void; -}; - -function OnboardingListElement({ +export function OnboardingListElement({ label, text, selected, @@ -46,20 +35,18 @@ function OnboardingListElement({