feat(platform): Onboarding Phase 2 (#9736)

### Changes 🏗️

- Update onboarding to give user rewards for completing steps
- Remove `canvas-confetti` lib and add `party-js` instead; the former
didn't allow to play confetti from a component
- Add onboarding videos in `frontend/public/onboarding/`
- Remove Balance (`CreditsCard.tsx`) and add openable `Wallet.tsx` (and
accompanying `WalletTaskGroup.tsx`) instead that displays grouped
onboarding tasks with descriptions and short instructional videos
- Further relevant updates to `useOnboarding`, `types.ts`
- Implement onboarding rewards
- Add `onboarding_reward` function in `credit.py` that is used to reward
user for finished onboarding tasks safely - transaction key is
deterministic, so the same user won't be rewarded twice for the same
step.
  - Add `reward_user` in `onboarding.py`
- Update `UserOnboarding` model and add a migration

<img width="464" alt="Screenshot 2025-04-05 at 6 06 29 PM"
src="https://github.com/user-attachments/assets/fca8d09e-0139-466b-b679-d24117ad01f0"
/>

### 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 works
  - [x] Tasks can be completed
  - [x] Rewards are added correctly for all completed tasks
This commit is contained in:
Krzysztof Czerwinski
2025-04-12 12:56:59 +02:00
committed by GitHub
parent bb92226f5d
commit d791cdea76
31 changed files with 698 additions and 131 deletions

View File

@@ -11,6 +11,7 @@ from prisma.enums import (
CreditRefundRequestStatus,
CreditTransactionType,
NotificationType,
OnboardingStep,
)
from prisma.errors import UniqueViolationError
from prisma.models import CreditRefundRequest, CreditTransaction, User
@@ -121,6 +122,18 @@ class UserCreditBase(ABC):
"""
pass
@abstractmethod
async def onboarding_reward(self, user_id: str, credits: int, step: OnboardingStep):
"""
Reward the user with credits for completing an onboarding step.
Won't reward if the user has already received credits for the step.
Args:
user_id (str): The user ID.
step (OnboardingStep): The onboarding step.
"""
pass
@abstractmethod
async def top_up_intent(self, user_id: str, amount: int) -> str:
"""
@@ -408,6 +421,24 @@ class UserCredit(UserCreditBase):
async def top_up_credits(self, user_id: str, amount: int):
await self._top_up_credits(user_id, amount)
async def onboarding_reward(self, user_id: str, credits: int, step: OnboardingStep):
key = f"REWARD-{user_id}-{step.value}"
if not await CreditTransaction.prisma().find_first(
where={
"userId": user_id,
"transactionKey": key,
}
):
await self._add_transaction(
user_id=user_id,
amount=credits,
transaction_type=CreditTransactionType.GRANT,
transaction_key=key,
metadata=Json(
{"reason": f"Reward for completing {step.value} onboarding step."}
),
)
async def top_up_refund(
self, user_id: str, transaction_key: str, metadata: dict[str, str]
) -> int:
@@ -895,6 +926,9 @@ class DisabledUserCredit(UserCreditBase):
async def top_up_credits(self, *args, **kwargs):
pass
async def onboarding_reward(self, *args, **kwargs):
pass
async def top_up_intent(self, *args, **kwargs) -> str:
return ""

View File

@@ -8,7 +8,9 @@ from prisma.enums import OnboardingStep
from prisma.models import UserOnboarding
from prisma.types import UserOnboardingCreateInput, UserOnboardingUpdateInput
from backend.data import db
from backend.data.block import get_blocks
from backend.data.credit import get_user_credit_model
from backend.data.graph import GraphModel
from backend.data.model import CredentialsMetaInput
from backend.server.v2.store.model import StoreAgentDetails
@@ -24,14 +26,19 @@ 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
user_credit = get_user_credit_model()
class UserOnboardingUpdate(pydantic.BaseModel):
completedSteps: Optional[list[OnboardingStep]] = None
notificationDot: Optional[bool] = None
notified: Optional[list[OnboardingStep]] = None
usageReason: Optional[str] = None
integrations: Optional[list[str]] = None
otherIntegrations: Optional[str] = None
selectedStoreListingVersionId: Optional[str] = None
agentInput: Optional[dict[str, Any]] = None
onboardingAgentExecutionId: Optional[str] = None
async def get_user_onboarding(user_id: str):
@@ -48,6 +55,20 @@ async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
update: UserOnboardingUpdateInput = {}
if data.completedSteps is not None:
update["completedSteps"] = list(set(data.completedSteps))
for step in (
OnboardingStep.AGENT_NEW_RUN,
OnboardingStep.GET_RESULTS,
OnboardingStep.MARKETPLACE_ADD_AGENT,
OnboardingStep.MARKETPLACE_RUN_AGENT,
OnboardingStep.BUILDER_SAVE_AGENT,
OnboardingStep.BUILDER_RUN_AGENT,
):
if step in data.completedSteps:
await reward_user(user_id, step)
if data.notificationDot is not None:
update["notificationDot"] = data.notificationDot
if data.notified is not None:
update["notified"] = list(set(data.notified))
if data.usageReason is not None:
update["usageReason"] = data.usageReason
if data.integrations is not None:
@@ -58,6 +79,8 @@ async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
update["selectedStoreListingVersionId"] = data.selectedStoreListingVersionId
if data.agentInput is not None:
update["agentInput"] = Json(data.agentInput)
if data.onboardingAgentExecutionId is not None:
update["onboardingAgentExecutionId"] = data.onboardingAgentExecutionId
return await UserOnboarding.prisma().upsert(
where={"userId": user_id},
@@ -68,6 +91,45 @@ async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
)
async def reward_user(user_id: str, step: OnboardingStep):
async with db.locked_transaction(f"usr_trx_{user_id}-reward"):
reward = 0
match step:
# Reward user when they clicked New Run during onboarding
# This is because they need credits before scheduling a run (next step)
case OnboardingStep.AGENT_NEW_RUN:
reward = 300
case OnboardingStep.GET_RESULTS:
reward = 300
case OnboardingStep.MARKETPLACE_ADD_AGENT:
reward = 100
case OnboardingStep.MARKETPLACE_RUN_AGENT:
reward = 100
case OnboardingStep.BUILDER_SAVE_AGENT:
reward = 100
case OnboardingStep.BUILDER_RUN_AGENT:
reward = 100
if reward == 0:
return
onboarding = await get_user_onboarding(user_id)
# Skip if already rewarded
if step in onboarding.rewardedFor:
return
onboarding.rewardedFor.append(step)
await user_credit.onboarding_reward(user_id, reward, step)
await UserOnboarding.prisma().update(
where={"userId": user_id},
data={
"completedSteps": list(set(onboarding.completedSteps + [step])),
"rewardedFor": onboarding.rewardedFor,
},
)
def clean_and_split(text: str) -> list[str]:
"""
Removes all special characters from a string, truncates it to 100 characters,

View File

@@ -0,0 +1,16 @@
-- Modify the OnboardingStep enum
ALTER TYPE "OnboardingStep" ADD VALUE 'GET_RESULTS';
ALTER TYPE "OnboardingStep" ADD VALUE 'MARKETPLACE_VISIT';
ALTER TYPE "OnboardingStep" ADD VALUE 'MARKETPLACE_ADD_AGENT';
ALTER TYPE "OnboardingStep" ADD VALUE 'MARKETPLACE_RUN_AGENT';
ALTER TYPE "OnboardingStep" ADD VALUE 'BUILDER_OPEN';
ALTER TYPE "OnboardingStep" ADD VALUE 'BUILDER_SAVE_AGENT';
ALTER TYPE "OnboardingStep" ADD VALUE 'BUILDER_RUN_AGENT';
-- Modify the UserOnboarding table
ALTER TABLE "UserOnboarding"
ADD COLUMN "updatedAt" TIMESTAMP(3),
ADD COLUMN "notificationDot" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "notified" "OnboardingStep"[] DEFAULT '{}',
ADD COLUMN "rewardedFor" "OnboardingStep"[] DEFAULT '{}',
ADD COLUMN "onboardingAgentExecutionId" TEXT

View File

@@ -58,6 +58,7 @@ model User {
}
enum OnboardingStep {
// Introductory onboarding (Library)
WELCOME
USAGE_REASON
INTEGRATIONS
@@ -65,18 +66,32 @@ enum OnboardingStep {
AGENT_NEW_RUN
AGENT_INPUT
CONGRATS
GET_RESULTS
// Marketplace
MARKETPLACE_VISIT
MARKETPLACE_ADD_AGENT
MARKETPLACE_RUN_AGENT
// Builder
BUILDER_OPEN
BUILDER_SAVE_AGENT
BUILDER_RUN_AGENT
}
model UserOnboarding {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
completedSteps OnboardingStep[] @default([])
notificationDot Boolean @default(true)
notified OnboardingStep[] @default([])
rewardedFor OnboardingStep[] @default([])
usageReason String?
integrations String[] @default([])
otherIntegrations String?
selectedStoreListingVersionId String?
agentInput Json?
onboardingAgentExecutionId String?
userId String @unique
User User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -52,7 +52,6 @@
"@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",
@@ -70,6 +69,7 @@
"moment": "^2.30.1",
"next": "^14.2.26",
"next-themes": "^0.4.5",
"party-js": "^2.2.0",
"react": "^18",
"react-day-picker": "^9.6.1",
"react-dom": "^18",

View File

@@ -3,9 +3,16 @@
import { useSearchParams } from "next/navigation";
import { GraphID } from "@/lib/autogpt-server-api/types";
import FlowEditor from "@/components/Flow";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import { useEffect } from "react";
export default function Home() {
const query = useSearchParams();
const { completeStep } = useOnboarding();
useEffect(() => {
completeStep("BUILDER_OPEN");
}, []);
return (
<FlowEditor

View File

@@ -22,6 +22,7 @@ import AgentRunDraftView from "@/components/agents/agent-run-draft-view";
import AgentRunDetailsView from "@/components/agents/agent-run-details-view";
import AgentRunsSelectorList from "@/components/agents/agent-runs-selector-list";
import AgentScheduleDetailsView from "@/components/agents/agent-schedule-details-view";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
export default function AgentRunsPage(): React.ReactElement {
const { id: agentID }: { id: LibraryAgentID } = useParams();
@@ -49,6 +50,7 @@ export default function AgentRunsPage(): React.ReactElement {
useState<boolean>(false);
const [confirmingDeleteAgentRun, setConfirmingDeleteAgentRun] =
useState<GraphExecutionMeta | null>(null);
const { state, updateState } = useOnboarding();
const openRunDraftView = useCallback(() => {
selectView({ type: "run" });
@@ -78,6 +80,18 @@ export default function AgentRunsPage(): React.ReactElement {
[api, graphVersions],
);
// Reward user for viewing results of their onboarding agent
useEffect(() => {
if (!state || !selectedRun || state.completedSteps.includes("GET_RESULTS"))
return;
if (selectedRun.id === state.onboardingAgentExecutionId) {
updateState({
completedSteps: [...state.completedSteps, "GET_RESULTS"],
});
}
}, [selectedRun, state]);
const fetchAgents = useCallback(() => {
api.getLibraryAgent(agentID).then((agent) => {
setAgent(agent);

View File

@@ -83,8 +83,14 @@ export default function Page() {
api.addMarketplaceAgentToLibrary(
storeAgent?.store_listing_version_id || "",
);
api.executeGraph(agent.id, agent.version, state?.agentInput || {});
router.push("/onboarding/6-congrats");
api
.executeGraph(agent.id, agent.version, state?.agentInput || {})
.then(({ graph_exec_id }) => {
updateState({
onboardingAgentExecutionId: graph_exec_id,
});
router.push("/onboarding/6-congrats");
});
}, [api, agent, router, state?.agentInput]);
const runYourAgent = (

View File

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

View File

@@ -1,24 +1,26 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { finishOnboarding } from "./actions";
import confetti from "canvas-confetti";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import * as party from "party-js";
export default function Page() {
useOnboarding(7, "AGENT_INPUT");
const { state, updateState } = useOnboarding(7, "AGENT_INPUT");
const [showText, setShowText] = useState(false);
const [showSubtext, setShowSubtext] = useState(false);
const divRef = useRef(null);
useEffect(() => {
confetti({
particleCount: 120,
spread: 360,
shapes: ["square", "circle"],
scalar: 2,
decay: 0.93,
origin: { y: 0.38, x: 0.51 },
});
if (divRef.current) {
party.confetti(divRef.current, {
count: 100,
spread: 180,
shapes: ["square", "circle"],
size: party.variation.range(2, 2), // scalar: 2
speed: party.variation.range(300, 1000),
});
}
const timer0 = setTimeout(() => {
setShowText(true);
@@ -29,6 +31,9 @@ export default function Page() {
}, 500);
const timer2 = setTimeout(() => {
updateState({
completedSteps: [...(state?.completedSteps || []), "CONGRATS"],
});
finishOnboarding();
}, 3000);
@@ -42,6 +47,7 @@ export default function Page() {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center bg-violet-100">
<div
ref={divRef}
className={cn(
"z-10 -mb-16 text-9xl duration-500",
showText ? "opacity-100" : "opacity-0",
@@ -63,7 +69,7 @@ export default function Page() {
showSubtext ? "opacity-100" : "opacity-0",
)}
>
You earned 15$ for running your first agent
You earned 3$ for running your first agent
</p>
</div>
);

View File

@@ -5,11 +5,14 @@ export default async function OnboardingResetPage() {
const api = new BackendAPI();
await api.updateUserOnboarding({
completedSteps: [],
notificationDot: true,
notified: [],
usageReason: null,
integrations: [],
otherIntegrations: "",
selectedStoreListingVersionId: null,
agentInput: {},
onboardingAgentExecutionId: null,
});
redirect("/onboarding/1-welcome");
}

View File

@@ -11,6 +11,7 @@ import { useToastOnFail } from "@/components/ui/use-toast";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import SchemaTooltip from "@/components/SchemaTooltip";
import { IconPlay } from "@/components/ui/icons";
import { useOnboarding } from "../onboarding/onboarding-provider";
export default function AgentRunDraftView({
graph,
@@ -26,15 +27,18 @@ export default function AgentRunDraftView({
const agentInputs = graph.input_schema.properties;
const [inputValues, setInputValues] = useState<Record<string, any>>({});
const { state, completeStep } = useOnboarding();
const doRun = useCallback(
() =>
api
.executeGraph(graph.id, graph.version, inputValues)
.then((newRun) => onRun(newRun.graph_exec_id))
.catch(toastOnFail("execute agent")),
[api, graph, inputValues, onRun, toastOnFail],
);
const doRun = useCallback(() => {
api
.executeGraph(graph.id, graph.version, inputValues)
.then((newRun) => onRun(newRun.graph_exec_id))
.catch(toastOnFail("execute agent"));
// Mark run agent onboarding step as completed
if (state?.completedSteps.includes("MARKETPLACE_ADD_AGENT")) {
completeStep("MARKETPLACE_RUN_AGENT");
}
}, [api, graph, inputValues, onRun, state]);
const runActions: ButtonAction[] = useMemo(
() => [

View File

@@ -10,6 +10,7 @@ import { useToast } from "@/components/ui/use-toast";
import useSupabase from "@/hooks/useSupabase";
import { DownloadIcon, LoaderIcon } from "lucide-react";
import { useOnboarding } from "../onboarding/onboarding-provider";
interface AgentInfoProps {
name: string;
creator: string;
@@ -39,6 +40,7 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
const api = React.useMemo(() => new BackendAPI(), []);
const { user } = useSupabase();
const { toast } = useToast();
const { completeStep } = useOnboarding();
const [downloading, setDownloading] = React.useState(false);
@@ -47,6 +49,7 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
const newLibraryAgent = await api.addMarketplaceAgentToLibrary(
storeListingVersionId,
);
completeStep("MARKETPLACE_ADD_AGENT");
router.push(`/library/agents/${newLibraryAgent.id}`);
} catch (error) {
console.error("Failed to add agent to library:", error);

View File

@@ -1,43 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import CreditsCard from "./CreditsCard";
import { userEvent, within } from "@storybook/test";
const meta: Meta<typeof CreditsCard> = {
title: "AGPT UI/Credits Card",
component: CreditsCard,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof CreditsCard>;
export const Default: Story = {
args: {
credits: 0,
},
};
export const SmallNumber: Story = {
args: {
credits: 10,
},
};
export const LargeNumber: Story = {
args: {
credits: 1000000,
},
};
export const InteractionTest: Story = {
args: {
credits: 100,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const refreshButton = canvas.getByRole("button", {
name: /refresh credits/i,
});
await userEvent.click(refreshButton);
},
};

View File

@@ -1,48 +0,0 @@
"use client";
import { IconRefresh } from "@/components/ui/icons";
import { useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import useCredits from "@/hooks/useCredits";
const CreditsCard = () => {
const { credits, formatCredits, fetchCredits } = useCredits({
fetchInitialCredits: true,
});
const api = useBackendAPI();
const onRefresh = async () => {
fetchCredits();
};
return (
<div className="inline-flex h-[48px] items-center gap-2.5 rounded-2xl bg-neutral-200 p-4 dark:bg-neutral-800">
<div className="flex items-center gap-0.5">
<span className="p-ui-semibold text-base leading-7 text-neutral-900 dark:text-neutral-50">
Balance: {formatCredits(credits)}
</span>
</div>
<Tooltip key="RefreshCredits" delayDuration={500}>
<TooltipTrigger asChild>
<button
onClick={onRefresh}
className="h-6 w-6 transition-colors hover:text-neutral-700 dark:hover:text-neutral-300"
aria-label="Refresh credits"
>
<IconRefresh className="h-6 w-6" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh credits</p>
</TooltipContent>
</Tooltip>
</div>
);
};
export default CreditsCard;

View File

@@ -4,7 +4,7 @@ import { ProfilePopoutMenu } from "./ProfilePopoutMenu";
import { IconType, IconLogIn, IconAutoGPTLogo } from "@/components/ui/icons";
import { MobileNavBar } from "./MobileNavBar";
import { Button } from "./Button";
import CreditsCard from "./CreditsCard";
import Wallet from "./Wallet";
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
import { NavbarLink } from "./NavbarLink";
import getServerUser from "@/lib/supabase/getServerUser";
@@ -61,7 +61,7 @@ export const Navbar = async ({ links, menuItemGroups }: NavbarProps) => {
<div className="flex items-center gap-4">
{isLoggedIn ? (
<div className="flex items-center gap-4">
{profile && <CreditsCard />}
{profile && <Wallet />}
<ProfilePopoutMenu
menuItemGroups={menuItemGroups}
userName={profile?.username}

View File

@@ -0,0 +1,114 @@
"use client";
import useCredits from "@/hooks/useCredits";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { X } from "lucide-react";
import { PopoverClose } from "@radix-ui/react-popover";
import { TaskGroups } from "../onboarding/WalletTaskGroups";
import { ScrollArea } from "../ui/scroll-area";
import { useOnboarding } from "../onboarding/onboarding-provider";
import { useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import * as party from "party-js";
export default function Wallet() {
const { credits, formatCredits, fetchCredits } = useCredits({
fetchInitialCredits: true,
});
const { state, updateState } = useOnboarding();
const walletRef = useRef<HTMLButtonElement | null>(null);
const onWalletOpen = useCallback(async () => {
if (state?.notificationDot) {
updateState({ notificationDot: false });
}
// Refresh credits when the wallet is opened
fetchCredits();
}, [state?.notificationDot, updateState, fetchCredits]);
const fadeOut = new party.ModuleBuilder()
.drive("opacity")
.by((t) => 1 - t)
.through("lifetime")
.build();
useEffect(() => {
// Check if there are any completed tasks (state?.completedTasks) that
// are not in the state?.notified array and play confetti if so
const pending = state?.completedSteps
.filter((step) => !state?.notified.includes(step))
// Ignore steps that are not relevant for notifications
.filter(
(step) =>
step !== "WELCOME" &&
step !== "USAGE_REASON" &&
step !== "INTEGRATIONS" &&
step !== "AGENT_CHOICE" &&
step !== "AGENT_NEW_RUN" &&
step !== "AGENT_INPUT",
);
if ((pending?.length || 0) > 0 && walletRef.current) {
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],
});
}
}, [state?.completedSteps, state?.notified]);
return (
<Popover>
<PopoverTrigger asChild>
<button
ref={walletRef}
className="relative flex items-center gap-1 rounded-md bg-zinc-200 px-3 py-2 text-sm transition-colors duration-200 hover:bg-zinc-300"
onClick={onWalletOpen}
>
Wallet{" "}
<span className="text-sm font-semibold">
{formatCredits(credits)}
</span>
{state?.notificationDot && (
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-violet-600"></span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className={cn(
"absolute -right-[7.9rem] -top-[3.2rem] z-50 w-[28.5rem] px-[0.625rem] py-2",
"rounded-xl border-zinc-200 bg-zinc-50 shadow-[0_3px_3px] shadow-zinc-300",
)}
>
<div>
<div className="mx-1 flex items-center justify-between border-b border-zinc-300 pb-2">
<span className="font-poppins font-medium text-zinc-900">
Your wallet
</span>
<div className="flex items-center font-inter text-sm font-semibold text-violet-700">
<div className="rounded-lg bg-violet-100 px-3 py-2">
Wallet{" "}
<span className="font-semibold">{formatCredits(credits)}</span>
</div>
<PopoverClose>
<X className="ml-[2.8rem] h-5 w-5 text-zinc-800 hover:text-foreground" />
</PopoverClose>
</div>
</div>
<p className="mx-1 mt-3 font-inter text-xs text-muted-foreground text-zinc-400">
Complete the following tasks to earn more credits!
</p>
</div>
<ScrollArea className="max-h-[80vh] overflow-y-auto">
<TaskGroups />
</ScrollArea>
</PopoverContent>
</Popover>
);
}

View File

@@ -4,9 +4,16 @@ import * as React from "react";
import { SearchBar } from "@/components/agptui/SearchBar";
import { FilterChips } from "@/components/agptui/FilterChips";
import { useRouter } from "next/navigation";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
export const HeroSection: React.FC = () => {
const router = useRouter();
const { completeStep } = useOnboarding();
// Mark marketplace visit task as completed
React.useEffect(() => {
completeStep("MARKETPLACE_VISIT");
}, [completeStep]);
function onFilterChange(selectedFilters: string[]) {
const encodedTerm = encodeURIComponent(selectedFilters.join(", "));

View File

@@ -0,0 +1,331 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronDown, Check } from "lucide-react";
import { OnboardingStep } from "@/lib/autogpt-server-api";
import { useOnboarding } from "./onboarding-provider";
import { cn } from "@/lib/utils";
import * as party from "party-js";
interface Task {
id: OnboardingStep;
name: string;
amount: number;
details: string;
video?: string;
}
interface TaskGroup {
name: string;
tasks: Task[];
isOpen: boolean;
}
export function TaskGroups() {
const [groups, setGroups] = useState<TaskGroup[]>([
{
name: "Run your first agent",
isOpen: false,
tasks: [
{
id: "CONGRATS",
name: "Finish onboarding",
amount: 3,
details: "Go through our step by step tutorial",
},
{
id: "GET_RESULTS",
name: "Get results from first agent",
amount: 3,
details:
"Sit back and relax - your agent is running and will finish soon! See the results in the Library once it's done",
video: "/onboarding/get-results.mp4",
},
],
},
{
name: "Explore the Marketplace",
isOpen: false,
tasks: [
{
id: "MARKETPLACE_VISIT",
name: "Go to Marketplace",
amount: 0,
details: "Click Marketplace in the top navigation",
video: "/onboarding/marketplace-visit.mp4",
},
{
id: "MARKETPLACE_ADD_AGENT",
name: "Find an agent",
amount: 1,
details:
"Search for an agent in the Marketplace, like a code generator or research assistant and add it to your Library",
video: "/onboarding/marketplace-add.mp4",
},
{
id: "MARKETPLACE_RUN_AGENT",
name: "Try out your agent",
amount: 1,
details:
"Run the agent you found in the Marketplace from the Library - whether it's a writing assistant, data analyzer, or something else",
video: "/onboarding/marketplace-run.mp4",
},
],
},
{
name: "Build your own agent",
isOpen: false,
tasks: [
{
id: "BUILDER_OPEN",
name: "Open the Builder",
amount: 0,
details: "Click Builder in the top navigation",
video: "/onboarding/builder-open.mp4",
},
{
id: "BUILDER_SAVE_AGENT",
name: "Place your first blocks and save your agent",
amount: 1,
details:
"Open block library on the left and add a block to the canvas then save your agent",
video: "/onboarding/builder-save.mp4",
},
{
id: "BUILDER_RUN_AGENT",
name: "Run your agent",
amount: 1,
details: "Run your agent from the Builder",
video: "/onboarding/builder-run.mp4",
},
],
},
]);
const { state, updateState } = useOnboarding();
const refs = useRef<Record<string, HTMLDivElement | null>>({});
const toggleGroup = useCallback((name: string) => {
setGroups((prevGroups) =>
prevGroups.map((group) =>
group.name === name ? { ...group, isOpen: !group.isOpen } : group,
),
);
}, []);
const isTaskCompleted = useCallback(
(task: Task) => {
return state?.completedSteps?.includes(task.id) || false;
},
[state?.completedSteps],
);
const getCompletedCount = useCallback(
(tasks: Task[]) => {
return tasks.filter((task) => isTaskCompleted(task)).length;
},
[isTaskCompleted],
);
const isGroupCompleted = useCallback(
(group: TaskGroup) => {
return group.tasks.every((task) => isTaskCompleted(task));
},
[isTaskCompleted],
);
const setRef = (name: string) => (el: HTMLDivElement | null) => {
if (el) {
refs.current[name] = el;
}
};
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,
);
if (groupCompleted) {
const el = refs.current[group.name];
if (el && !alreadyCelebrated) {
party.confetti(el, {
count: 50,
spread: 120,
shapes: ["square", "circle"],
size: party.variation.range(1, 2),
speed: party.variation.range(200, 300),
});
// Update the state to include all group tasks as notified
// This ensures that the confetti effect isn't perpetually triggered on Wallet
const notifiedTasks = group.tasks.map((task) => task.id);
updateState({
notified: [...(state?.notified || []), ...notifiedTasks],
});
}
return;
}
group.tasks.forEach((task) => {
const el = refs.current[task.id];
if (el && isTaskCompleted(task) && !state?.notified.includes(task.id)) {
party.confetti(el, {
count: 40,
spread: 120,
shapes: ["square", "circle"],
size: party.variation.range(1, 1.5),
speed: party.variation.range(200, 300),
});
// Update the state to include the task as notified
updateState({ notified: [...(state?.notified || []), task.id] });
}
});
});
}, [state?.completedSteps]);
return (
<div className="space-y-2">
{groups.map((group) => (
<div
key={group.name}
ref={setRef(group.name)}
className="mt-3 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-100"
>
{/* Group Header - unchanged */}
<div
className="flex cursor-pointer items-center justify-between p-3"
onClick={() => toggleGroup(group.name)}
>
{/* Name and completed count */}
<div className="flex-1">
<div
className={cn(
"text-sm font-medium text-zinc-900",
isGroupCompleted(group) ? "text-zinc-600 line-through" : "",
)}
>
{group.name}
</div>
<div
className={cn(
"mt-1 text-xs font-normal leading-tight text-zinc-500",
isGroupCompleted(group) ? "line-through" : "",
)}
>
{getCompletedCount(group.tasks)} of {group.tasks.length}{" "}
completed
</div>
</div>
{/* Reward and chevron */}
<div className="flex items-center gap-2">
<div
className={cn(
"text-xs font-medium leading-tight text-violet-600",
isGroupCompleted(group) ? "line-through" : "",
)}
>
$
{group.tasks
.reduce((sum, task) => sum + task.amount, 0)
.toFixed(2)}
</div>
<ChevronDown
className={`h-5 w-5 text-slate-950 transition-transform duration-300 ease-in-out ${
group.isOpen ? "rotate-180" : ""
}`}
/>
</div>
</div>
{/* Tasks */}
<div
className={cn(
"overflow-hidden transition-all duration-300 ease-in-out",
group.isOpen || !isGroupCompleted(group)
? "max-h-[1000px] opacity-100"
: "max-h-0 opacity-0",
)}
>
{group.tasks.map((task) => (
<div
key={task.id}
ref={setRef(task.id)}
className="mx-3 border-t border-zinc-300 px-1 pb-1 pt-3"
>
<div className="flex items-center justify-between">
{/* Checkmark and name */}
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded-full border",
isTaskCompleted(task)
? "border-emerald-600"
: "border-zinc-600",
)}
>
{isTaskCompleted(task) && (
<Check className="h-3 w-3 text-emerald-600" />
)}
</div>
<span
className={cn(
"text-sm font-normal",
isTaskCompleted(task)
? "text-zinc-500 line-through"
: "text-zinc-800",
)}
>
{task.name}
</span>
</div>
{/* Reward */}
{task.amount > 0 && (
<span
className={cn(
"text-xs font-normal text-zinc-500",
isTaskCompleted(task) ? "line-through" : "",
)}
>
${task.amount.toFixed(2)}
</span>
)}
</div>
{/* Details section */}
<div
className={cn(
"mt-2 overflow-hidden pl-6 text-xs font-normal text-zinc-500 transition-all duration-300 ease-in-out",
isTaskCompleted(task) && "line-through",
group.isOpen
? "max-h-[100px] opacity-100"
: "max-h-0 opacity-0",
)}
>
{task.details}
</div>
{task.video && (
<div
className={cn(
"relative mx-6 aspect-video overflow-hidden rounded-lg transition-all duration-300 ease-in-out",
group.isOpen
? "my-2 max-h-[200px] opacity-100"
: "max-h-0 opacity-0",
)}
>
<video
src={task.video}
autoPlay
loop
muted
playsInline
className={cn("h-full w-full object-cover object-center")}
></video>
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -14,9 +14,12 @@ import {
const OnboardingContext = createContext<
| {
state: UserOnboarding | null;
updateState: (state: Partial<UserOnboarding>) => void;
updateState: (
state: Omit<Partial<UserOnboarding>, "rewardedFor">,
) => void;
step: number;
setStep: (step: number) => void;
completeStep: (step: OnboardingStep) => void;
}
| undefined
>(undefined);
@@ -84,19 +87,23 @@ export default function OnboardingProvider({
}, [api, pathname, router]);
const updateState = useCallback(
(newState: Partial<UserOnboarding>) => {
(newState: Omit<Partial<UserOnboarding>, "rewardedFor">) => {
setState((prev) => {
api.updateUserOnboarding({ ...prev, ...newState });
api.updateUserOnboarding(newState);
if (!prev) {
// Handle initial state
return {
completedSteps: [],
notificationDot: false,
notified: [],
rewardedFor: [],
usageReason: null,
integrations: [],
otherIntegrations: null,
selectedStoreListingVersionId: null,
agentInput: null,
onboardingAgentExecutionId: null,
...newState,
};
}
@@ -106,8 +113,21 @@ export default function OnboardingProvider({
[api, setState],
);
const completeStep = useCallback(
(step: OnboardingStep) => {
if (!state || state.completedSteps.includes(step)) return;
updateState({
completedSteps: [...state.completedSteps, step],
});
},
[api, state],
);
return (
<OnboardingContext.Provider value={{ state, updateState, step, setStep }}>
<OnboardingContext.Provider
value={{ state, updateState, step, setStep, completeStep }}
>
{children}
</OnboardingContext.Provider>
);

View File

@@ -24,6 +24,7 @@ import { useToast } from "@/components/ui/use-toast";
import { InputItem } from "@/components/RunnerUIWrapper";
import { GraphMeta } from "@/lib/autogpt-server-api";
import { default as NextLink } from "next/link";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
const ajv = new Ajv({ strict: false, allErrors: true });
@@ -77,6 +78,7 @@ export default function useAgentGraph(
useState(false);
const [nodes, setNodes] = useState<CustomNode[]>([]);
const [edges, setEdges] = useState<CustomEdge[]>([]);
const { state, completeStep } = useOnboarding();
const api = useMemo(
() => new BackendAPI(process.env.NEXT_PUBLIC_AGPT_SERVER_URL!),
@@ -576,6 +578,9 @@ export default function useAgentGraph(
path.set("flowVersion", savedAgent.version.toString());
path.set("flowExecutionID", graphExecution.graph_exec_id);
router.push(`${pathname}?${path.toString()}`);
if (state?.completedSteps.includes("BUILDER_SAVE_AGENT")) {
completeStep("BUILDER_RUN_AGENT");
}
})
.catch((error) => {
const errorMessage =
@@ -966,6 +971,7 @@ export default function useAgentGraph(
const saveAgent = useCallback(async () => {
try {
await _saveAgent();
completeStep("BUILDER_SAVE_AGENT");
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);

View File

@@ -180,7 +180,9 @@ export default class BackendAPI {
return this._get("/onboarding");
}
updateUserOnboarding(onboarding: Partial<UserOnboarding>): Promise<void> {
updateUserOnboarding(
onboarding: Omit<Partial<UserOnboarding>, "rewardedFor">,
): Promise<void> {
return this._request("PATCH", "/onboarding", onboarding);
}

View File

@@ -801,15 +801,26 @@ export type OnboardingStep =
| "AGENT_CHOICE"
| "AGENT_NEW_RUN"
| "AGENT_INPUT"
| "CONGRATS";
| "CONGRATS"
| "GET_RESULTS"
| "MARKETPLACE_VISIT"
| "MARKETPLACE_ADD_AGENT"
| "MARKETPLACE_RUN_AGENT"
| "BUILDER_OPEN"
| "BUILDER_SAVE_AGENT"
| "BUILDER_RUN_AGENT";
export interface UserOnboarding {
completedSteps: OnboardingStep[];
notificationDot: boolean;
notified: OnboardingStep[];
rewardedFor: OnboardingStep[];
usageReason: string | null;
integrations: string[];
otherIntegrations: string | null;
selectedStoreListingVersionId: string | null;
agentInput: { [key: string]: string } | null;
agentInput: { [key: string]: string | number } | null;
onboardingAgentExecutionId: GraphExecutionID | null;
}
/* *** UTILITIES *** */

View File

@@ -5026,11 +5026,6 @@ caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001688:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001697.tgz#040bbbb54463c4b4b3377c716b34a322d16e6fc7"
integrity sha512-GwNPlWJin8E+d7Gxq96jxM6w0w+VFeyyXRsjU58emtkYqnbwHqXm5uT2uCmO0RQE9htWknOP4xtBlLmM/gWxvQ==
canvas-confetti@^1.9.3:
version "1.9.3"
resolved "https://registry.yarnpkg.com/canvas-confetti/-/canvas-confetti-1.9.3.tgz#ef4c857420ad8045ab4abe8547261c8cdf229845"
integrity sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==
case-sensitive-paths-webpack-plugin@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4"
@@ -9590,6 +9585,11 @@ parse-passwd@^1.0.0:
resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
integrity sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==
party-js@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/party-js/-/party-js-2.2.0.tgz#3340026971c9e62fd34db102daaa645fbc9130b8"
integrity sha512-50hGuALCpvDTrQLPQ1fgUgxKIWAH28ShVkmeK/3zhO0YJyCqkhrZhQEkWPxDYLvbFJ7YAXyROmFEu35gKpZLtQ==
pascal-case@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb"