mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 15:17:59 -05:00
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:
committed by
GitHub
parent
bb92226f5d
commit
d791cdea76
@@ -11,6 +11,7 @@ from prisma.enums import (
|
|||||||
CreditRefundRequestStatus,
|
CreditRefundRequestStatus,
|
||||||
CreditTransactionType,
|
CreditTransactionType,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
|
OnboardingStep,
|
||||||
)
|
)
|
||||||
from prisma.errors import UniqueViolationError
|
from prisma.errors import UniqueViolationError
|
||||||
from prisma.models import CreditRefundRequest, CreditTransaction, User
|
from prisma.models import CreditRefundRequest, CreditTransaction, User
|
||||||
@@ -121,6 +122,18 @@ class UserCreditBase(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
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
|
@abstractmethod
|
||||||
async def top_up_intent(self, user_id: str, amount: int) -> str:
|
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):
|
async def top_up_credits(self, user_id: str, amount: int):
|
||||||
await self._top_up_credits(user_id, amount)
|
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(
|
async def top_up_refund(
|
||||||
self, user_id: str, transaction_key: str, metadata: dict[str, str]
|
self, user_id: str, transaction_key: str, metadata: dict[str, str]
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -895,6 +926,9 @@ class DisabledUserCredit(UserCreditBase):
|
|||||||
async def top_up_credits(self, *args, **kwargs):
|
async def top_up_credits(self, *args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def onboarding_reward(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
async def top_up_intent(self, *args, **kwargs) -> str:
|
async def top_up_intent(self, *args, **kwargs) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ from prisma.enums import OnboardingStep
|
|||||||
from prisma.models import UserOnboarding
|
from prisma.models import UserOnboarding
|
||||||
from prisma.types import UserOnboardingCreateInput, UserOnboardingUpdateInput
|
from prisma.types import UserOnboardingCreateInput, UserOnboardingUpdateInput
|
||||||
|
|
||||||
|
from backend.data import db
|
||||||
from backend.data.block import get_blocks
|
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.graph import GraphModel
|
||||||
from backend.data.model import CredentialsMetaInput
|
from backend.data.model import CredentialsMetaInput
|
||||||
from backend.server.v2.store.model import StoreAgentDetails
|
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
|
POINTS_AGENT_COUNT = 50 # Number of agents to calculate points for
|
||||||
MIN_AGENT_COUNT = 2 # Minimum number of marketplace agents to enable onboarding
|
MIN_AGENT_COUNT = 2 # Minimum number of marketplace agents to enable onboarding
|
||||||
|
|
||||||
|
user_credit = get_user_credit_model()
|
||||||
|
|
||||||
|
|
||||||
class UserOnboardingUpdate(pydantic.BaseModel):
|
class UserOnboardingUpdate(pydantic.BaseModel):
|
||||||
completedSteps: Optional[list[OnboardingStep]] = None
|
completedSteps: Optional[list[OnboardingStep]] = None
|
||||||
|
notificationDot: Optional[bool] = None
|
||||||
|
notified: Optional[list[OnboardingStep]] = None
|
||||||
usageReason: Optional[str] = None
|
usageReason: Optional[str] = None
|
||||||
integrations: Optional[list[str]] = None
|
integrations: Optional[list[str]] = None
|
||||||
otherIntegrations: Optional[str] = None
|
otherIntegrations: Optional[str] = None
|
||||||
selectedStoreListingVersionId: Optional[str] = None
|
selectedStoreListingVersionId: Optional[str] = None
|
||||||
agentInput: Optional[dict[str, Any]] = None
|
agentInput: Optional[dict[str, Any]] = None
|
||||||
|
onboardingAgentExecutionId: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
async def get_user_onboarding(user_id: str):
|
async def get_user_onboarding(user_id: str):
|
||||||
@@ -48,6 +55,20 @@ async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
|
|||||||
update: UserOnboardingUpdateInput = {}
|
update: UserOnboardingUpdateInput = {}
|
||||||
if data.completedSteps is not None:
|
if data.completedSteps is not None:
|
||||||
update["completedSteps"] = list(set(data.completedSteps))
|
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:
|
if data.usageReason is not None:
|
||||||
update["usageReason"] = data.usageReason
|
update["usageReason"] = data.usageReason
|
||||||
if data.integrations is not None:
|
if data.integrations is not None:
|
||||||
@@ -58,6 +79,8 @@ async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
|
|||||||
update["selectedStoreListingVersionId"] = data.selectedStoreListingVersionId
|
update["selectedStoreListingVersionId"] = data.selectedStoreListingVersionId
|
||||||
if data.agentInput is not None:
|
if data.agentInput is not None:
|
||||||
update["agentInput"] = Json(data.agentInput)
|
update["agentInput"] = Json(data.agentInput)
|
||||||
|
if data.onboardingAgentExecutionId is not None:
|
||||||
|
update["onboardingAgentExecutionId"] = data.onboardingAgentExecutionId
|
||||||
|
|
||||||
return await UserOnboarding.prisma().upsert(
|
return await UserOnboarding.prisma().upsert(
|
||||||
where={"userId": user_id},
|
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]:
|
def clean_and_split(text: str) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Removes all special characters from a string, truncates it to 100 characters,
|
Removes all special characters from a string, truncates it to 100 characters,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -58,6 +58,7 @@ model User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum OnboardingStep {
|
enum OnboardingStep {
|
||||||
|
// Introductory onboarding (Library)
|
||||||
WELCOME
|
WELCOME
|
||||||
USAGE_REASON
|
USAGE_REASON
|
||||||
INTEGRATIONS
|
INTEGRATIONS
|
||||||
@@ -65,18 +66,32 @@ enum OnboardingStep {
|
|||||||
AGENT_NEW_RUN
|
AGENT_NEW_RUN
|
||||||
AGENT_INPUT
|
AGENT_INPUT
|
||||||
CONGRATS
|
CONGRATS
|
||||||
|
GET_RESULTS
|
||||||
|
// Marketplace
|
||||||
|
MARKETPLACE_VISIT
|
||||||
|
MARKETPLACE_ADD_AGENT
|
||||||
|
MARKETPLACE_RUN_AGENT
|
||||||
|
// Builder
|
||||||
|
BUILDER_OPEN
|
||||||
|
BUILDER_SAVE_AGENT
|
||||||
|
BUILDER_RUN_AGENT
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserOnboarding {
|
model UserOnboarding {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime? @updatedAt
|
||||||
|
|
||||||
completedSteps OnboardingStep[] @default([])
|
completedSteps OnboardingStep[] @default([])
|
||||||
|
notificationDot Boolean @default(true)
|
||||||
|
notified OnboardingStep[] @default([])
|
||||||
|
rewardedFor OnboardingStep[] @default([])
|
||||||
usageReason String?
|
usageReason String?
|
||||||
integrations String[] @default([])
|
integrations String[] @default([])
|
||||||
otherIntegrations String?
|
otherIntegrations String?
|
||||||
selectedStoreListingVersionId String?
|
selectedStoreListingVersionId String?
|
||||||
agentInput Json?
|
agentInput Json?
|
||||||
|
onboardingAgentExecutionId String?
|
||||||
|
|
||||||
userId String @unique
|
userId String @unique
|
||||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -52,7 +52,6 @@
|
|||||||
"@xyflow/react": "12.4.2",
|
"@xyflow/react": "12.4.2",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"boring-avatars": "^1.11.2",
|
"boring-avatars": "^1.11.2",
|
||||||
"canvas-confetti": "^1.9.3",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
@@ -70,6 +69,7 @@
|
|||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "^14.2.26",
|
"next": "^14.2.26",
|
||||||
"next-themes": "^0.4.5",
|
"next-themes": "^0.4.5",
|
||||||
|
"party-js": "^2.2.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-day-picker": "^9.6.1",
|
"react-day-picker": "^9.6.1",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
|||||||
BIN
autogpt_platform/frontend/public/onboarding/builder-open.mp4
Normal file
BIN
autogpt_platform/frontend/public/onboarding/builder-open.mp4
Normal file
Binary file not shown.
BIN
autogpt_platform/frontend/public/onboarding/builder-run.mp4
Normal file
BIN
autogpt_platform/frontend/public/onboarding/builder-run.mp4
Normal file
Binary file not shown.
BIN
autogpt_platform/frontend/public/onboarding/builder-save.mp4
Normal file
BIN
autogpt_platform/frontend/public/onboarding/builder-save.mp4
Normal file
Binary file not shown.
BIN
autogpt_platform/frontend/public/onboarding/get-results.mp4
Normal file
BIN
autogpt_platform/frontend/public/onboarding/get-results.mp4
Normal file
Binary file not shown.
BIN
autogpt_platform/frontend/public/onboarding/marketplace-add.mp4
Normal file
BIN
autogpt_platform/frontend/public/onboarding/marketplace-add.mp4
Normal file
Binary file not shown.
BIN
autogpt_platform/frontend/public/onboarding/marketplace-run.mp4
Normal file
BIN
autogpt_platform/frontend/public/onboarding/marketplace-run.mp4
Normal file
Binary file not shown.
Binary file not shown.
@@ -3,9 +3,16 @@
|
|||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { GraphID } from "@/lib/autogpt-server-api/types";
|
import { GraphID } from "@/lib/autogpt-server-api/types";
|
||||||
import FlowEditor from "@/components/Flow";
|
import FlowEditor from "@/components/Flow";
|
||||||
|
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const query = useSearchParams();
|
const query = useSearchParams();
|
||||||
|
const { completeStep } = useOnboarding();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
completeStep("BUILDER_OPEN");
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlowEditor
|
<FlowEditor
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import AgentRunDraftView from "@/components/agents/agent-run-draft-view";
|
|||||||
import AgentRunDetailsView from "@/components/agents/agent-run-details-view";
|
import AgentRunDetailsView from "@/components/agents/agent-run-details-view";
|
||||||
import AgentRunsSelectorList from "@/components/agents/agent-runs-selector-list";
|
import AgentRunsSelectorList from "@/components/agents/agent-runs-selector-list";
|
||||||
import AgentScheduleDetailsView from "@/components/agents/agent-schedule-details-view";
|
import AgentScheduleDetailsView from "@/components/agents/agent-schedule-details-view";
|
||||||
|
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
|
||||||
|
|
||||||
export default function AgentRunsPage(): React.ReactElement {
|
export default function AgentRunsPage(): React.ReactElement {
|
||||||
const { id: agentID }: { id: LibraryAgentID } = useParams();
|
const { id: agentID }: { id: LibraryAgentID } = useParams();
|
||||||
@@ -49,6 +50,7 @@ export default function AgentRunsPage(): React.ReactElement {
|
|||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
const [confirmingDeleteAgentRun, setConfirmingDeleteAgentRun] =
|
const [confirmingDeleteAgentRun, setConfirmingDeleteAgentRun] =
|
||||||
useState<GraphExecutionMeta | null>(null);
|
useState<GraphExecutionMeta | null>(null);
|
||||||
|
const { state, updateState } = useOnboarding();
|
||||||
|
|
||||||
const openRunDraftView = useCallback(() => {
|
const openRunDraftView = useCallback(() => {
|
||||||
selectView({ type: "run" });
|
selectView({ type: "run" });
|
||||||
@@ -78,6 +80,18 @@ export default function AgentRunsPage(): React.ReactElement {
|
|||||||
[api, graphVersions],
|
[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(() => {
|
const fetchAgents = useCallback(() => {
|
||||||
api.getLibraryAgent(agentID).then((agent) => {
|
api.getLibraryAgent(agentID).then((agent) => {
|
||||||
setAgent(agent);
|
setAgent(agent);
|
||||||
|
|||||||
@@ -83,8 +83,14 @@ export default function Page() {
|
|||||||
api.addMarketplaceAgentToLibrary(
|
api.addMarketplaceAgentToLibrary(
|
||||||
storeAgent?.store_listing_version_id || "",
|
storeAgent?.store_listing_version_id || "",
|
||||||
);
|
);
|
||||||
api.executeGraph(agent.id, agent.version, state?.agentInput || {});
|
api
|
||||||
router.push("/onboarding/6-congrats");
|
.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]);
|
}, [api, agent, router, state?.agentInput]);
|
||||||
|
|
||||||
const runYourAgent = (
|
const runYourAgent = (
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import { redirect } from "next/navigation";
|
|||||||
export async function finishOnboarding() {
|
export async function finishOnboarding() {
|
||||||
const api = new BackendAPI();
|
const api = new BackendAPI();
|
||||||
const onboarding = await api.getUserOnboarding();
|
const onboarding = await api.getUserOnboarding();
|
||||||
await api.updateUserOnboarding({
|
|
||||||
completedSteps: [...onboarding.completedSteps, "CONGRATS"],
|
|
||||||
});
|
|
||||||
revalidatePath("/library", "layout");
|
revalidatePath("/library", "layout");
|
||||||
redirect("/library");
|
redirect("/library");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { finishOnboarding } from "./actions";
|
import { finishOnboarding } from "./actions";
|
||||||
import confetti from "canvas-confetti";
|
|
||||||
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
|
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
|
||||||
|
import * as party from "party-js";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
useOnboarding(7, "AGENT_INPUT");
|
const { state, updateState } = useOnboarding(7, "AGENT_INPUT");
|
||||||
const [showText, setShowText] = useState(false);
|
const [showText, setShowText] = useState(false);
|
||||||
const [showSubtext, setShowSubtext] = useState(false);
|
const [showSubtext, setShowSubtext] = useState(false);
|
||||||
|
const divRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
confetti({
|
if (divRef.current) {
|
||||||
particleCount: 120,
|
party.confetti(divRef.current, {
|
||||||
spread: 360,
|
count: 100,
|
||||||
shapes: ["square", "circle"],
|
spread: 180,
|
||||||
scalar: 2,
|
shapes: ["square", "circle"],
|
||||||
decay: 0.93,
|
size: party.variation.range(2, 2), // scalar: 2
|
||||||
origin: { y: 0.38, x: 0.51 },
|
speed: party.variation.range(300, 1000),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const timer0 = setTimeout(() => {
|
const timer0 = setTimeout(() => {
|
||||||
setShowText(true);
|
setShowText(true);
|
||||||
@@ -29,6 +31,9 @@ export default function Page() {
|
|||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
const timer2 = setTimeout(() => {
|
const timer2 = setTimeout(() => {
|
||||||
|
updateState({
|
||||||
|
completedSteps: [...(state?.completedSteps || []), "CONGRATS"],
|
||||||
|
});
|
||||||
finishOnboarding();
|
finishOnboarding();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
@@ -42,6 +47,7 @@ export default function Page() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col items-center justify-center bg-violet-100">
|
<div className="flex h-screen w-screen flex-col items-center justify-center bg-violet-100">
|
||||||
<div
|
<div
|
||||||
|
ref={divRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-10 -mb-16 text-9xl duration-500",
|
"z-10 -mb-16 text-9xl duration-500",
|
||||||
showText ? "opacity-100" : "opacity-0",
|
showText ? "opacity-100" : "opacity-0",
|
||||||
@@ -63,7 +69,7 @@ export default function Page() {
|
|||||||
showSubtext ? "opacity-100" : "opacity-0",
|
showSubtext ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
You earned 15$ for running your first agent
|
You earned 3$ for running your first agent
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ export default async function OnboardingResetPage() {
|
|||||||
const api = new BackendAPI();
|
const api = new BackendAPI();
|
||||||
await api.updateUserOnboarding({
|
await api.updateUserOnboarding({
|
||||||
completedSteps: [],
|
completedSteps: [],
|
||||||
|
notificationDot: true,
|
||||||
|
notified: [],
|
||||||
usageReason: null,
|
usageReason: null,
|
||||||
integrations: [],
|
integrations: [],
|
||||||
otherIntegrations: "",
|
otherIntegrations: "",
|
||||||
selectedStoreListingVersionId: null,
|
selectedStoreListingVersionId: null,
|
||||||
agentInput: {},
|
agentInput: {},
|
||||||
|
onboardingAgentExecutionId: null,
|
||||||
});
|
});
|
||||||
redirect("/onboarding/1-welcome");
|
redirect("/onboarding/1-welcome");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useToastOnFail } from "@/components/ui/use-toast";
|
|||||||
import ActionButtonGroup from "@/components/agptui/action-button-group";
|
import ActionButtonGroup from "@/components/agptui/action-button-group";
|
||||||
import SchemaTooltip from "@/components/SchemaTooltip";
|
import SchemaTooltip from "@/components/SchemaTooltip";
|
||||||
import { IconPlay } from "@/components/ui/icons";
|
import { IconPlay } from "@/components/ui/icons";
|
||||||
|
import { useOnboarding } from "../onboarding/onboarding-provider";
|
||||||
|
|
||||||
export default function AgentRunDraftView({
|
export default function AgentRunDraftView({
|
||||||
graph,
|
graph,
|
||||||
@@ -26,15 +27,18 @@ export default function AgentRunDraftView({
|
|||||||
|
|
||||||
const agentInputs = graph.input_schema.properties;
|
const agentInputs = graph.input_schema.properties;
|
||||||
const [inputValues, setInputValues] = useState<Record<string, any>>({});
|
const [inputValues, setInputValues] = useState<Record<string, any>>({});
|
||||||
|
const { state, completeStep } = useOnboarding();
|
||||||
|
|
||||||
const doRun = useCallback(
|
const doRun = useCallback(() => {
|
||||||
() =>
|
api
|
||||||
api
|
.executeGraph(graph.id, graph.version, inputValues)
|
||||||
.executeGraph(graph.id, graph.version, inputValues)
|
.then((newRun) => onRun(newRun.graph_exec_id))
|
||||||
.then((newRun) => onRun(newRun.graph_exec_id))
|
.catch(toastOnFail("execute agent"));
|
||||||
.catch(toastOnFail("execute agent")),
|
// Mark run agent onboarding step as completed
|
||||||
[api, graph, inputValues, onRun, toastOnFail],
|
if (state?.completedSteps.includes("MARKETPLACE_ADD_AGENT")) {
|
||||||
);
|
completeStep("MARKETPLACE_RUN_AGENT");
|
||||||
|
}
|
||||||
|
}, [api, graph, inputValues, onRun, state]);
|
||||||
|
|
||||||
const runActions: ButtonAction[] = useMemo(
|
const runActions: ButtonAction[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useToast } from "@/components/ui/use-toast";
|
|||||||
|
|
||||||
import useSupabase from "@/hooks/useSupabase";
|
import useSupabase from "@/hooks/useSupabase";
|
||||||
import { DownloadIcon, LoaderIcon } from "lucide-react";
|
import { DownloadIcon, LoaderIcon } from "lucide-react";
|
||||||
|
import { useOnboarding } from "../onboarding/onboarding-provider";
|
||||||
interface AgentInfoProps {
|
interface AgentInfoProps {
|
||||||
name: string;
|
name: string;
|
||||||
creator: string;
|
creator: string;
|
||||||
@@ -39,6 +40,7 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
|
|||||||
const api = React.useMemo(() => new BackendAPI(), []);
|
const api = React.useMemo(() => new BackendAPI(), []);
|
||||||
const { user } = useSupabase();
|
const { user } = useSupabase();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { completeStep } = useOnboarding();
|
||||||
|
|
||||||
const [downloading, setDownloading] = React.useState(false);
|
const [downloading, setDownloading] = React.useState(false);
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ export const AgentInfo: React.FC<AgentInfoProps> = ({
|
|||||||
const newLibraryAgent = await api.addMarketplaceAgentToLibrary(
|
const newLibraryAgent = await api.addMarketplaceAgentToLibrary(
|
||||||
storeListingVersionId,
|
storeListingVersionId,
|
||||||
);
|
);
|
||||||
|
completeStep("MARKETPLACE_ADD_AGENT");
|
||||||
router.push(`/library/agents/${newLibraryAgent.id}`);
|
router.push(`/library/agents/${newLibraryAgent.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add agent to library:", error);
|
console.error("Failed to add agent to library:", error);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
@@ -4,7 +4,7 @@ import { ProfilePopoutMenu } from "./ProfilePopoutMenu";
|
|||||||
import { IconType, IconLogIn, IconAutoGPTLogo } from "@/components/ui/icons";
|
import { IconType, IconLogIn, IconAutoGPTLogo } from "@/components/ui/icons";
|
||||||
import { MobileNavBar } from "./MobileNavBar";
|
import { MobileNavBar } from "./MobileNavBar";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import CreditsCard from "./CreditsCard";
|
import Wallet from "./Wallet";
|
||||||
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
|
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
|
||||||
import { NavbarLink } from "./NavbarLink";
|
import { NavbarLink } from "./NavbarLink";
|
||||||
import getServerUser from "@/lib/supabase/getServerUser";
|
import getServerUser from "@/lib/supabase/getServerUser";
|
||||||
@@ -61,7 +61,7 @@ export const Navbar = async ({ links, menuItemGroups }: NavbarProps) => {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{profile && <CreditsCard />}
|
{profile && <Wallet />}
|
||||||
<ProfilePopoutMenu
|
<ProfilePopoutMenu
|
||||||
menuItemGroups={menuItemGroups}
|
menuItemGroups={menuItemGroups}
|
||||||
userName={profile?.username}
|
userName={profile?.username}
|
||||||
|
|||||||
114
autogpt_platform/frontend/src/components/agptui/Wallet.tsx
Normal file
114
autogpt_platform/frontend/src/components/agptui/Wallet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,16 @@ import * as React from "react";
|
|||||||
import { SearchBar } from "@/components/agptui/SearchBar";
|
import { SearchBar } from "@/components/agptui/SearchBar";
|
||||||
import { FilterChips } from "@/components/agptui/FilterChips";
|
import { FilterChips } from "@/components/agptui/FilterChips";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
|
||||||
|
|
||||||
export const HeroSection: React.FC = () => {
|
export const HeroSection: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { completeStep } = useOnboarding();
|
||||||
|
|
||||||
|
// Mark marketplace visit task as completed
|
||||||
|
React.useEffect(() => {
|
||||||
|
completeStep("MARKETPLACE_VISIT");
|
||||||
|
}, [completeStep]);
|
||||||
|
|
||||||
function onFilterChange(selectedFilters: string[]) {
|
function onFilterChange(selectedFilters: string[]) {
|
||||||
const encodedTerm = encodeURIComponent(selectedFilters.join(", "));
|
const encodedTerm = encodeURIComponent(selectedFilters.join(", "));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,9 +14,12 @@ import {
|
|||||||
const OnboardingContext = createContext<
|
const OnboardingContext = createContext<
|
||||||
| {
|
| {
|
||||||
state: UserOnboarding | null;
|
state: UserOnboarding | null;
|
||||||
updateState: (state: Partial<UserOnboarding>) => void;
|
updateState: (
|
||||||
|
state: Omit<Partial<UserOnboarding>, "rewardedFor">,
|
||||||
|
) => void;
|
||||||
step: number;
|
step: number;
|
||||||
setStep: (step: number) => void;
|
setStep: (step: number) => void;
|
||||||
|
completeStep: (step: OnboardingStep) => void;
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
@@ -84,19 +87,23 @@ export default function OnboardingProvider({
|
|||||||
}, [api, pathname, router]);
|
}, [api, pathname, router]);
|
||||||
|
|
||||||
const updateState = useCallback(
|
const updateState = useCallback(
|
||||||
(newState: Partial<UserOnboarding>) => {
|
(newState: Omit<Partial<UserOnboarding>, "rewardedFor">) => {
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
api.updateUserOnboarding({ ...prev, ...newState });
|
api.updateUserOnboarding(newState);
|
||||||
|
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
// Handle initial state
|
// Handle initial state
|
||||||
return {
|
return {
|
||||||
completedSteps: [],
|
completedSteps: [],
|
||||||
|
notificationDot: false,
|
||||||
|
notified: [],
|
||||||
|
rewardedFor: [],
|
||||||
usageReason: null,
|
usageReason: null,
|
||||||
integrations: [],
|
integrations: [],
|
||||||
otherIntegrations: null,
|
otherIntegrations: null,
|
||||||
selectedStoreListingVersionId: null,
|
selectedStoreListingVersionId: null,
|
||||||
agentInput: null,
|
agentInput: null,
|
||||||
|
onboardingAgentExecutionId: null,
|
||||||
...newState,
|
...newState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -106,8 +113,21 @@ export default function OnboardingProvider({
|
|||||||
[api, setState],
|
[api, setState],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const completeStep = useCallback(
|
||||||
|
(step: OnboardingStep) => {
|
||||||
|
if (!state || state.completedSteps.includes(step)) return;
|
||||||
|
|
||||||
|
updateState({
|
||||||
|
completedSteps: [...state.completedSteps, step],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[api, state],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingContext.Provider value={{ state, updateState, step, setStep }}>
|
<OnboardingContext.Provider
|
||||||
|
value={{ state, updateState, step, setStep, completeStep }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</OnboardingContext.Provider>
|
</OnboardingContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useToast } from "@/components/ui/use-toast";
|
|||||||
import { InputItem } from "@/components/RunnerUIWrapper";
|
import { InputItem } from "@/components/RunnerUIWrapper";
|
||||||
import { GraphMeta } from "@/lib/autogpt-server-api";
|
import { GraphMeta } from "@/lib/autogpt-server-api";
|
||||||
import { default as NextLink } from "next/link";
|
import { default as NextLink } from "next/link";
|
||||||
|
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
|
||||||
|
|
||||||
const ajv = new Ajv({ strict: false, allErrors: true });
|
const ajv = new Ajv({ strict: false, allErrors: true });
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ export default function useAgentGraph(
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [nodes, setNodes] = useState<CustomNode[]>([]);
|
const [nodes, setNodes] = useState<CustomNode[]>([]);
|
||||||
const [edges, setEdges] = useState<CustomEdge[]>([]);
|
const [edges, setEdges] = useState<CustomEdge[]>([]);
|
||||||
|
const { state, completeStep } = useOnboarding();
|
||||||
|
|
||||||
const api = useMemo(
|
const api = useMemo(
|
||||||
() => new BackendAPI(process.env.NEXT_PUBLIC_AGPT_SERVER_URL!),
|
() => 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("flowVersion", savedAgent.version.toString());
|
||||||
path.set("flowExecutionID", graphExecution.graph_exec_id);
|
path.set("flowExecutionID", graphExecution.graph_exec_id);
|
||||||
router.push(`${pathname}?${path.toString()}`);
|
router.push(`${pathname}?${path.toString()}`);
|
||||||
|
if (state?.completedSteps.includes("BUILDER_SAVE_AGENT")) {
|
||||||
|
completeStep("BUILDER_RUN_AGENT");
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -966,6 +971,7 @@ export default function useAgentGraph(
|
|||||||
const saveAgent = useCallback(async () => {
|
const saveAgent = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await _saveAgent();
|
await _saveAgent();
|
||||||
|
completeStep("BUILDER_SAVE_AGENT");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -180,7 +180,9 @@ export default class BackendAPI {
|
|||||||
return this._get("/onboarding");
|
return this._get("/onboarding");
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUserOnboarding(onboarding: Partial<UserOnboarding>): Promise<void> {
|
updateUserOnboarding(
|
||||||
|
onboarding: Omit<Partial<UserOnboarding>, "rewardedFor">,
|
||||||
|
): Promise<void> {
|
||||||
return this._request("PATCH", "/onboarding", onboarding);
|
return this._request("PATCH", "/onboarding", onboarding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -801,15 +801,26 @@ export type OnboardingStep =
|
|||||||
| "AGENT_CHOICE"
|
| "AGENT_CHOICE"
|
||||||
| "AGENT_NEW_RUN"
|
| "AGENT_NEW_RUN"
|
||||||
| "AGENT_INPUT"
|
| "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 {
|
export interface UserOnboarding {
|
||||||
completedSteps: OnboardingStep[];
|
completedSteps: OnboardingStep[];
|
||||||
|
notificationDot: boolean;
|
||||||
|
notified: OnboardingStep[];
|
||||||
|
rewardedFor: OnboardingStep[];
|
||||||
usageReason: string | null;
|
usageReason: string | null;
|
||||||
integrations: string[];
|
integrations: string[];
|
||||||
otherIntegrations: string | null;
|
otherIntegrations: string | null;
|
||||||
selectedStoreListingVersionId: string | null;
|
selectedStoreListingVersionId: string | null;
|
||||||
agentInput: { [key: string]: string } | null;
|
agentInput: { [key: string]: string | number } | null;
|
||||||
|
onboardingAgentExecutionId: GraphExecutionID | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* *** UTILITIES *** */
|
/* *** UTILITIES *** */
|
||||||
|
|||||||
@@ -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"
|
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001697.tgz#040bbbb54463c4b4b3377c716b34a322d16e6fc7"
|
||||||
integrity sha512-GwNPlWJin8E+d7Gxq96jxM6w0w+VFeyyXRsjU58emtkYqnbwHqXm5uT2uCmO0RQE9htWknOP4xtBlLmM/gWxvQ==
|
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:
|
case-sensitive-paths-webpack-plugin@^2.4.0:
|
||||||
version "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"
|
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"
|
resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
|
||||||
integrity sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==
|
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:
|
pascal-case@^3.1.2:
|
||||||
version "3.1.2"
|
version "3.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb"
|
resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb"
|
||||||
|
|||||||
Reference in New Issue
Block a user