Compare commits

...

6 Commits

Author SHA1 Message Date
Reinier van der Leer de44d7eb01
fix login and signup page 2025-01-21 22:22:59 +01:00
Reinier van der Leer 427258115e
fix navbar and monitoring page sizing 2025-01-21 22:13:25 +01:00
Nicholas Tindle e53f1eaf80
feat: no longer require ollama key (#9287)
<!-- Clearly explain the need for these changes: -->

### Changes 🏗️

<!-- Concisely describe all of the changes made in this pull request:
-->

### Checklist 📋

#### For code changes:
- [ ] I have clearly listed my changes in the PR description
- [ ] I have made a test plan
- [ ] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [ ] ...

<details>
  <summary>Example test plan</summary>
  
  - [ ] Create from scratch and execute an agent with at least 3 blocks
- [ ] Import an agent from file upload, and confirm it executes
correctly
  - [ ] Upload agent to marketplace
- [ ] Import an agent from marketplace and confirm it executes correctly
  - [ ] Edit an agent from monitor, and confirm it executes correctly
</details>

#### For configuration changes:
- [ ] `.env.example` is updated or already compatible with my changes
- [ ] `docker-compose.yml` is updated or already compatible with my
changes
- [ ] I have included a list of my configuration changes in the PR
description (under **Changes**)

<details>
  <summary>Examples of configuration changes</summary>

  - Changing ports
  - Adding new services that need to communicate with each other
  - Secrets or environment variable changes
  - New or infrastructure changes such as databases
</details>
2025-01-16 12:25:08 +00:00
Krzysztof Czerwinski 04915f2db0
feat(platform): Implement top-up flow for PAYG System (#9050)
This PR adds Stripe integration and payment processing for topping-up
user accounts with credits.

### Changes 🏗️

Includes:
- https://github.com/Significant-Gravitas/AutoGPT/pull/9176

#### Top-up flow

1. To top-up a user visits their settings and clicks `Credits` button
(it's unavailable if `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` isn't present)
2. User inputs top-up amount (min 5$ in 1$ increments) and click the
button to confirm.
3. Backend receives top-up request, creates database entry and requests
stripe to provide url for this specific checkout.
4. User gets redirected to externally hosted Stripe checkout page, after
payment (or cancelling) they get redirected back to Credits page.
5. In the meantime Stripe processes payment and sends webhook
confirmation to the backend, backend updates database to activate bought
credits.
6. Credits page shows success (or failure) information (by using url
param `topup=success|cancel`). Credit counter won't update without
refreshing the page unless payment was confirmed before user was back on
Credits page which is the case when testing checkout locally.

<img width="804" alt="Screenshot 2025-01-01 at 2 55 35 PM"
src="https://github.com/user-attachments/assets/22fb518d-b30b-4154-bb4b-edea1d57b6c2"
/>

#### Backend
- Add `stripe` package
- Add environment variables:
  - `STRIPE_API_KEY`
  - `STRIPE_WEBHOOK_SECRET`
- Add routes:
  - `POST /credits`: top-up request, returns Stripe checkout url.
- `POST /credits/stripe_webhook`: Stripe webhook endpoint to notify of
successful payment.
- `PATCH /credits`: prompts beckend to check payment status. It's an
additional failsafe in case webhook fails.
- Update `credit.py` and related files to handle top-up request and
payment confirmation

#### Frontend
- Add `stripe-js` package
- Add `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` environment variable
- Modify user settings sidebar to show `Credits` if
`NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` is available
- Add `store/credits` page where user can top-up their account, it shows
confirmation (or failure) after completing checkout.
- Add `useCredits` hook that returns user credits and allows to request
top-up.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [ ] I have made a test plan
- [ ] I have tested my changes according to the test plan:
  <!-- Put your test plan here: -->
  - [ ] ...

<details>
  <summary>Example test plan</summary>
  
  - [ ] Create from scratch and execute an agent with at least 3 blocks
- [ ] Import an agent from file upload, and confirm it executes
correctly
  - [ ] Upload agent to marketplace
- [ ] Import an agent from marketplace and confirm it executes correctly
  - [ ] Edit an agent from monitor, and confirm it executes correctly
</details>

#### For configuration changes:
- [x] `.env.example` is updated or already compatible with my changes
- [ ] `docker-compose.yml` is updated or already compatible with my
changes
- [ ] I have included a list of my configuration changes in the PR
description (under **Changes**)

<details>
  <summary>Examples of configuration changes</summary>

  - Changing ports
  - Adding new services that need to communicate with each other
  - Secrets or environment variable changes
  - New or infrastructure changes such as databases
</details>

---------

Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
2025-01-15 23:46:52 +00:00
Nicholas Tindle 9d79bfadea
[Snyk] Security upgrade next from 14.2.20 to 14.2.21 (#9243)
![snyk-top-banner](https://redirect.github.com/andygongea/OWASP-Benchmark/assets/818805/c518c423-16fe-447e-b67f-ad5a49b5d123)

### Snyk has created this PR to fix 1 vulnerabilities in the yarn
dependencies of this project.

#### Snyk changed the following file(s):

- `autogpt_platform/frontend/package.json`
- `autogpt_platform/frontend/yarn.lock`


#### Note for
[zero-installs](https://yarnpkg.com/features/zero-installs) users

If you are using the Yarn feature
[zero-installs](https://yarnpkg.com/features/zero-installs) that was
introduced in Yarn V2, note that this PR does not update the
`.yarn/cache/` directory meaning this code cannot be pulled and
immediately developed on as one would expect for a zero-install project
- you will need to run `yarn` to update the contents of the
`./yarn/cache` directory.
If you are not using zero-install you can ignore this as your flow
should likely be unchanged.




#### Vulnerabilities that will be fixed with an upgrade:

|  | Issue |  
:-------------------------:|:-------------------------
![medium
severity](https://res.cloudinary.com/snyk/image/upload/w_20,h_20/v1561977819/icon/m.png
'medium severity') | Allocation of Resources Without Limits or
Throttling
<br/>[SNYK-JS-NEXT-8602067](https://snyk.io/vuln/SNYK-JS-NEXT-8602067)




---

> [!IMPORTANT]
>
> - Check the changes in this PR to ensure they won't cause issues with
your project.
> - Max score is 1000. Note that the real score may have changed since
the PR was raised.
> - This PR was automatically created by Snyk using the credentials of a
real user.

---

**Note:** _You are seeing this because you or someone else with access
to this repository has authorized Snyk to open fix PRs._

For more information: <img
src="https://api.segment.io/v1/pixel/track?data=eyJ3cml0ZUtleSI6InJyWmxZcEdHY2RyTHZsb0lYd0dUcVg4WkFRTnNCOUEwIiwiYW5vbnltb3VzSWQiOiI4NWY3NDgyYy03NGFiLTQxNmYtYjQ4OC0wMTUwMDlmYzY5NzkiLCJldmVudCI6IlBSIHZpZXdlZCIsInByb3BlcnRpZXMiOnsicHJJZCI6Ijg1Zjc0ODJjLTc0YWItNDE2Zi1iNDg4LTAxNTAwOWZjNjk3OSJ9fQ=="
width="0" height="0"/>
🧐 [View latest project
report](https://app.snyk.io/org/significant-gravitas/project/3d924968-0cf3-4767-9609-501fa4962856?utm_source&#x3D;github&amp;utm_medium&#x3D;referral&amp;page&#x3D;fix-pr)
📜 [Customise PR
templates](https://docs.snyk.io/scan-using-snyk/pull-requests/snyk-fix-pull-or-merge-requests/customize-pr-templates?utm_source=github&utm_content=fix-pr-template)
🛠 [Adjust project
settings](https://app.snyk.io/org/significant-gravitas/project/3d924968-0cf3-4767-9609-501fa4962856?utm_source&#x3D;github&amp;utm_medium&#x3D;referral&amp;page&#x3D;fix-pr/settings)
📚 [Read about Snyk's upgrade
logic](https://docs.snyk.io/scan-with-snyk/snyk-open-source/manage-vulnerabilities/upgrade-package-versions-to-fix-vulnerabilities?utm_source=github&utm_content=fix-pr-template)

---

**Learn how to fix vulnerabilities with free interactive lessons:**

🦉 [Allocation of Resources Without Limits or
Throttling](https://learn.snyk.io/lesson/no-rate-limiting/?loc&#x3D;fix-pr)

[//]: #
'snyk:metadata:{"customTemplate":{"variablesUsed":[],"fieldsUsed":[]},"dependencies":[{"name":"next","from":"14.2.20","to":"14.2.21"}],"env":"prod","issuesToFix":["SNYK-JS-NEXT-8602067"],"prId":"85f7482c-74ab-416f-b488-015009fc6979","prPublicId":"85f7482c-74ab-416f-b488-015009fc6979","packageManager":"yarn","priorityScoreList":[null],"projectPublicId":"3d924968-0cf3-4767-9609-501fa4962856","projectUrl":"https://app.snyk.io/org/significant-gravitas/project/3d924968-0cf3-4767-9609-501fa4962856?utm_source=github&utm_medium=referral&page=fix-pr","prType":"fix","templateFieldSources":{"branchName":"default","commitMessage":"default","description":"default","title":"default"},"templateVariants":["updated-fix-title"],"type":"auto","upgrade":["SNYK-JS-NEXT-8602067"],"vulns":["SNYK-JS-NEXT-8602067"],"patch":[],"isBreakingChange":false,"remediationStrategy":"vuln"}'

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2025-01-15 18:11:13 +00:00
Nicholas Tindle 5f50c4863d
test(frontend): Re-enable the tests in monitor.spec.ts and then ensure they pass (#9248)
Enable the tests in `monitor.spec.ts`.

* Remove `test.describe.skip` to enable the tests.
* Ensure the tests are now running and passing successfully.

---

For more details, open the [Copilot Workspace
session](https://copilot-workspace.githubnext.com/Significant-Gravitas/AutoGPT/pull/9248?shareId=edbd64cc-ea19-477b-be06-5eea84c28665).
2025-01-15 09:41:41 +00:00
41 changed files with 797 additions and 249 deletions

View File

@ -15,6 +15,9 @@ REDIS_PORT=6379
REDIS_PASSWORD=password
ENABLE_CREDIT=false
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
# What environment things should be logged under: local dev or prod
APP_ENV=local
# What environment to behave as: "local" or "cloud"
@ -36,7 +39,7 @@ SUPABASE_JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
## to use the platform's webhook-related functionality.
## If you are developing locally, you can use something like ngrok to get a publc URL
## and tunnel it to your locally running backend.
PLATFORM_BASE_URL=https://your-public-url-here
PLATFORM_BASE_URL=http://localhost:3000
## == INTEGRATION CREDENTIALS == ##
# Each set of server side credentials is required for the corresponding 3rd party

View File

@ -1,4 +1,4 @@
from typing import List, Optional
from typing import List
from pydantic import BaseModel

View File

@ -1,30 +1,32 @@
from abc import ABC, abstractmethod
from datetime import datetime, timezone
import stripe
from prisma import Json
from prisma.enums import CreditTransactionType
from prisma.errors import UniqueViolationError
from prisma.models import CreditTransaction
from prisma.models import CreditTransaction, User
from prisma.types import CreditTransactionCreateInput, CreditTransactionWhereInput
from backend.data import db
from backend.data.block import Block, BlockInput, get_block
from backend.data.block_cost_config import BLOCK_COSTS
from backend.data.cost import BlockCost, BlockCostType
from backend.util.settings import Config
from backend.data.user import get_user_by_id
from backend.util.settings import Settings
config = Config()
settings = Settings()
stripe.api_key = settings.secrets.stripe_api_key
class UserCreditBase(ABC):
def __init__(self, num_user_credits_refill: int):
self.num_user_credits_refill = num_user_credits_refill
@abstractmethod
async def get_or_refill_credit(self, user_id: str) -> int:
async def get_credits(self, user_id: str) -> int:
"""
Get the current credit for the user and refill if no transaction has been made in the current cycle.
Get the current credits for the user.
Returns:
int: The current credit for the user.
int: The current credits for the user.
"""
pass
@ -32,7 +34,6 @@ class UserCreditBase(ABC):
async def spend_credits(
self,
user_id: str,
user_credit: int,
block_id: str,
input_data: BlockInput,
data_size: float,
@ -43,7 +44,6 @@ class UserCreditBase(ABC):
Args:
user_id (str): The user ID.
user_credit (int): The current credit for the user.
block_id (str): The block ID.
input_data (BlockInput): The input data for the block.
data_size (float): The size of the data being processed.
@ -57,7 +57,7 @@ class UserCreditBase(ABC):
@abstractmethod
async def top_up_credits(self, user_id: str, amount: int):
"""
Top up the credits for the user.
Top up the credits for the user immediately.
Args:
user_id (str): The user ID.
@ -65,51 +65,139 @@ class UserCreditBase(ABC):
"""
pass
@abstractmethod
async def top_up_intent(self, user_id: str, amount: int) -> str:
"""
Create a payment intent to top up the credits for the user.
class UserCredit(UserCreditBase):
async def get_or_refill_credit(self, user_id: str) -> int:
cur_time = self.time_now()
cur_month = cur_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
nxt_month = (
cur_month.replace(month=cur_month.month + 1)
if cur_month.month < 12
else cur_month.replace(year=cur_month.year + 1, month=1)
Args:
user_id (str): The user ID.
amount (int): The amount of credits to top up.
Returns:
str: The redirect url to the payment page.
"""
pass
@abstractmethod
async def fulfill_checkout(
self, *, session_id: str | None = None, user_id: str | None = None
):
"""
Fulfill the Stripe checkout session.
Args:
session_id (str | None): The checkout session ID. Will try to fulfill most recent if None.
user_id (str | None): The user ID must be provided if session_id is None.
"""
pass
@staticmethod
def time_now() -> datetime:
return datetime.now(timezone.utc)
# ====== Transaction Helper Methods ====== #
# Any modifications to the transaction table should only be done through these methods #
async def _get_credits(self, user_id: str) -> tuple[int, datetime]:
"""
Returns the current balance of the user & the latest balance snapshot time.
"""
top_time = self.time_now()
snapshot = await CreditTransaction.prisma().find_first(
where={
"userId": user_id,
"createdAt": {"lte": top_time},
"isActive": True,
"runningBalance": {"not": None}, # type: ignore
},
order={"createdAt": "desc"},
)
if snapshot:
return snapshot.runningBalance or 0, snapshot.createdAt
user_credit = await CreditTransaction.prisma().group_by(
# No snapshot: Manually calculate balance using current month's transactions.
low_time = top_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
transactions = await CreditTransaction.prisma().group_by(
by=["userId"],
sum={"amount": True},
where={
"userId": user_id,
"createdAt": {"gte": cur_month, "lt": nxt_month},
"createdAt": {"gte": low_time, "lte": top_time},
"isActive": True,
},
)
transaction_balance = (
transactions[0].get("_sum", {}).get("amount", 0) if transactions else 0
)
return transaction_balance, datetime.min
if user_credit:
credit_sum = user_credit[0].get("_sum") or {}
return credit_sum.get("amount", 0)
async def _enable_transaction(
self, transaction_key: str, user_id: str, metadata: Json
):
key = f"MONTHLY-CREDIT-TOP-UP-{cur_month}"
transaction = await CreditTransaction.prisma().find_first_or_raise(
where={"transactionKey": transaction_key, "userId": user_id}
)
try:
await CreditTransaction.prisma().create(
if transaction.isActive:
return
async with db.locked_transaction(f"usr_trx_{user_id}"):
user_balance, _ = await self._get_credits(user_id)
await CreditTransaction.prisma().update(
where={
"creditTransactionIdentifier": {
"transactionKey": transaction_key,
"userId": user_id,
}
},
data={
"amount": self.num_user_credits_refill,
"type": CreditTransactionType.TOP_UP,
"userId": user_id,
"transactionKey": key,
"isActive": True,
"runningBalance": user_balance + transaction.amount,
"createdAt": self.time_now(),
}
"metadata": metadata,
},
)
except UniqueViolationError:
pass # Already refilled this month
return self.num_user_credits_refill
async def _add_transaction(
self,
user_id: str,
amount: int,
transaction_type: CreditTransactionType,
is_active: bool = True,
transaction_key: str | None = None,
block_id: str | None = None,
metadata: Json = Json({}),
):
async with db.locked_transaction(f"usr_trx_{user_id}"):
# Get latest balance snapshot
user_balance, _ = await self._get_credits(user_id)
if amount < 0 and user_balance < abs(amount):
raise ValueError(
f"Insufficient balance for user {user_id}, balance: {user_balance}, amount: {amount}"
)
@staticmethod
def time_now():
return datetime.now(timezone.utc)
# Create the transaction
transaction_data: CreditTransactionCreateInput = {
"userId": user_id,
"amount": amount,
"runningBalance": user_balance + amount,
"type": transaction_type,
"blockId": block_id,
"metadata": metadata,
"isActive": is_active,
"createdAt": self.time_now(),
}
if transaction_key:
transaction_data["transactionKey"] = transaction_key
await CreditTransaction.prisma().create(data=transaction_data)
return user_balance + amount
class UserCredit(UserCreditBase):
def _block_usage_cost(
self,
@ -148,8 +236,8 @@ class UserCredit(UserCreditBase):
) -> bool:
"""
Filter rules:
- If costFilter is an object, then check if costFilter is the subset of inputValues
- Otherwise, check if costFilter is equal to inputValues.
- If cost_filter is an object, then check if cost_filter is the subset of input_data
- Otherwise, check if cost_filter is equal to input_data.
- Undefined, null, and empty string are considered as equal.
"""
if not isinstance(cost_filter, dict) or not isinstance(input_data, dict):
@ -164,12 +252,10 @@ class UserCredit(UserCreditBase):
async def spend_credits(
self,
user_id: str,
user_credit: int,
block_id: str,
input_data: BlockInput,
data_size: float,
run_time: float,
validate_balance: bool = True,
) -> int:
block = get_block(block_id)
if not block:
@ -178,42 +264,169 @@ class UserCredit(UserCreditBase):
cost, matching_filter = self._block_usage_cost(
block=block, input_data=input_data, data_size=data_size, run_time=run_time
)
if cost <= 0:
if cost == 0:
return 0
if validate_balance and user_credit < cost:
raise ValueError(f"Insufficient credit: {user_credit} < {cost}")
await CreditTransaction.prisma().create(
data={
"userId": user_id,
"amount": -cost,
"type": CreditTransactionType.USAGE,
"blockId": block.id,
"metadata": Json(
{
"block": block.name,
"input": matching_filter,
}
),
"createdAt": self.time_now(),
}
await self._add_transaction(
user_id=user_id,
amount=-cost,
transaction_type=CreditTransactionType.USAGE,
block_id=block.id,
metadata=Json(
{
"block": block.name,
"input": matching_filter,
}
),
)
return cost
async def top_up_credits(self, user_id: str, amount: int):
await CreditTransaction.prisma().create(
data={
"userId": user_id,
"amount": amount,
"type": CreditTransactionType.TOP_UP,
"createdAt": self.time_now(),
}
if amount < 0:
raise ValueError(f"Top up amount must not be negative: {amount}")
await self._add_transaction(
user_id=user_id,
amount=amount,
transaction_type=CreditTransactionType.TOP_UP,
)
@staticmethod
async def _get_stripe_customer_id(user_id: str) -> str:
user = await get_user_by_id(user_id)
if not user:
raise ValueError(f"User not found: {user_id}")
if user.stripeCustomerId:
return user.stripeCustomerId
customer = stripe.Customer.create(name=user.name or "", email=user.email)
await User.prisma().update(
where={"id": user_id}, data={"stripeCustomerId": customer.id}
)
return customer.id
async def top_up_intent(self, user_id: str, amount: int) -> str:
# Create checkout session
# https://docs.stripe.com/checkout/quickstart?client=react
# unit_amount param is always in the smallest currency unit (so cents for usd)
# which is equal to amount of credits
checkout_session = stripe.checkout.Session.create(
customer=await self._get_stripe_customer_id(user_id),
line_items=[
{
"price_data": {
"currency": "usd",
"product_data": {
"name": "AutoGPT Platform Credits",
},
"unit_amount": amount,
},
"quantity": 1,
}
],
mode="payment",
success_url=settings.config.platform_base_url
+ "/store/credits?topup=success",
cancel_url=settings.config.platform_base_url
+ "/store/credits?topup=cancel",
)
# Create pending transaction
await self._add_transaction(
user_id=user_id,
amount=amount,
transaction_type=CreditTransactionType.TOP_UP,
transaction_key=checkout_session.id,
is_active=False,
metadata=Json({"checkout_session": checkout_session}),
)
return checkout_session.url or ""
# https://docs.stripe.com/checkout/fulfillment
async def fulfill_checkout(
self, *, session_id: str | None = None, user_id: str | None = None
):
if (not session_id and not user_id) or (session_id and user_id):
raise ValueError("Either session_id or user_id must be provided")
# Retrieve CreditTransaction
find_filter: CreditTransactionWhereInput = {
"type": CreditTransactionType.TOP_UP,
"isActive": False,
}
if session_id:
find_filter["transactionKey"] = session_id
if user_id:
find_filter["userId"] = user_id
# Find the most recent inactive top-up transaction
credit_transaction = await CreditTransaction.prisma().find_first_or_raise(
where=find_filter,
order={"createdAt": "desc"},
)
# This can be called multiple times for one id, so ignore if already fulfilled
if not credit_transaction:
return
# Retrieve the Checkout Session from the API
checkout_session = stripe.checkout.Session.retrieve(
credit_transaction.transactionKey
)
# Check the Checkout Session's payment_status property
# to determine if fulfillment should be performed
if checkout_session.payment_status in ["paid", "no_payment_required"]:
await self._enable_transaction(
transaction_key=credit_transaction.transactionKey,
user_id=credit_transaction.userId,
metadata=Json({"checkout_session": checkout_session}),
)
async def get_credits(self, user_id: str) -> int:
balance, _ = await self._get_credits(user_id)
return balance
class BetaUserCredit(UserCredit):
"""
This is a temporary class to handle the test user utilizing monthly credit refill.
TODO: Remove this class & its feature toggle.
"""
def __init__(self, num_user_credits_refill: int):
self.num_user_credits_refill = num_user_credits_refill
async def get_credits(self, user_id: str) -> int:
cur_time = self.time_now().date()
balance, snapshot_time = await self._get_credits(user_id)
if (snapshot_time.year, snapshot_time.month) == (cur_time.year, cur_time.month):
return balance
try:
await CreditTransaction.prisma().create(
data={
"transactionKey": f"MONTHLY-CREDIT-TOP-UP-{cur_time}",
"userId": user_id,
"amount": self.num_user_credits_refill,
"runningBalance": self.num_user_credits_refill,
"type": CreditTransactionType.TOP_UP,
"metadata": Json({}),
"isActive": True,
"createdAt": self.time_now(),
}
)
except UniqueViolationError:
pass # Already refilled this month
return self.num_user_credits_refill
class DisabledUserCredit(UserCreditBase):
async def get_or_refill_credit(self, *args, **kwargs) -> int:
async def get_credits(self, *args, **kwargs) -> int:
return 0
async def spend_credits(self, *args, **kwargs) -> int:
@ -222,12 +435,21 @@ class DisabledUserCredit(UserCreditBase):
async def top_up_credits(self, *args, **kwargs):
pass
async def top_up_intent(self, *args, **kwargs) -> str:
return ""
async def fulfill_checkout(self, *args, **kwargs):
pass
def get_user_credit_model() -> UserCreditBase:
if config.enable_credit.lower() == "true":
return UserCredit(config.num_user_credits_refill)
else:
return DisabledUserCredit(0)
if not settings.config.enable_credit:
return DisabledUserCredit()
if settings.config.enable_beta_monthly_credit:
return BetaUserCredit(settings.config.num_user_credits_refill)
return UserCredit()
def get_block_costs() -> dict[str, list[BlockCost]]:

View File

@ -1,5 +1,6 @@
import logging
import os
import zlib
from contextlib import asynccontextmanager
from uuid import uuid4
@ -54,6 +55,14 @@ async def transaction():
yield tx
@asynccontextmanager
async def locked_transaction(key: str):
lock_key = zlib.crc32(key.encode("utf-8"))
async with transaction() as tx:
await tx.execute_raw(f"SELECT pg_advisory_xact_lock({lock_key})")
yield tx
class BaseDbModel(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))

View File

@ -78,12 +78,8 @@ class DatabaseManager(AppService):
# Credits
user_credit_model = get_user_credit_model()
get_or_refill_credit = cast(
Callable[[Any, str], int],
exposed_run_and_wait(user_credit_model.get_or_refill_credit),
)
spend_credits = cast(
Callable[[Any, str, int, str, dict[str, str], float, float], int],
Callable[[Any, str, str, dict[str, str], float, float], int],
exposed_run_and_wait(user_credit_model.spend_credits),
)

View File

@ -183,9 +183,6 @@ def execute_node(
output_size = 0
end_status = ExecutionStatus.COMPLETED
credit = db_client.get_or_refill_credit(user_id)
if credit < 0:
raise ValueError(f"Insufficient credit: {credit}")
try:
for output_name, output_data in node_block.execute(
@ -241,7 +238,7 @@ def execute_node(
if res.end_time and res.start_time
else 0
)
db_client.spend_credits(user_id, credit, node_block.id, input_data, s, t)
db_client.spend_credits(user_id, node_block.id, input_data, s, t)
# Update execution stats
if execution_stats is not None:

View File

@ -23,6 +23,15 @@ from backend.util.settings import Settings
settings = Settings()
# This is an overrride since ollama doesn't actually require an API key, but the creddential system enforces one be attached
ollama_credentials = APIKeyCredentials(
id="744fdc56-071a-4761-b5a5-0af0ce10a2b5",
provider="ollama",
api_key=SecretStr("FAKE_API_KEY"),
title="Use Credits for Ollama",
expires_at=None,
)
revid_credentials = APIKeyCredentials(
id="fdb7f412-f519-48d1-9b5f-d2f73d0e01fe",
provider="revid",
@ -124,6 +133,7 @@ nvidia_credentials = APIKeyCredentials(
DEFAULT_CREDENTIALS = [
ollama_credentials,
revid_credentials,
ideogram_credentials,
replicate_credentials,
@ -169,6 +179,10 @@ class IntegrationCredentialsStore:
def get_all_creds(self, user_id: str) -> list[Credentials]:
users_credentials = self._get_user_integrations(user_id).credentials
all_credentials = users_credentials
# These will always be added
all_credentials.append(ollama_credentials)
# These will only be added if the API key is set
if settings.secrets.revid_api_key:
all_credentials.append(revid_credentials)
if settings.secrets.ideogram_api_key:

View File

@ -56,3 +56,8 @@ class SetGraphActiveVersion(pydantic.BaseModel):
class UpdatePermissionsRequest(pydantic.BaseModel):
permissions: List[APIKeyPermission]
class RequestTopUp(pydantic.BaseModel):
amount: int
"""Amount of credits to top up."""

View File

@ -4,10 +4,11 @@ from collections import defaultdict
from typing import TYPE_CHECKING, Annotated, Any, Sequence
import pydantic
import stripe
from autogpt_libs.auth.middleware import auth_middleware
from autogpt_libs.feature_flag.client import feature_flag
from autogpt_libs.utils.cache import thread_cached
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from typing_extensions import Optional, TypedDict
import backend.data.block
@ -40,6 +41,7 @@ from backend.server.model import (
CreateAPIKeyRequest,
CreateAPIKeyResponse,
CreateGraph,
RequestTopUp,
SetGraphActiveVersion,
UpdatePermissionsRequest,
)
@ -134,7 +136,54 @@ async def get_user_credits(
user_id: Annotated[str, Depends(get_user_id)],
) -> dict[str, int]:
# Credits can go negative, so ensure it's at least 0 for user to see.
return {"credits": max(await _user_credit_model.get_or_refill_credit(user_id), 0)}
return {"credits": max(await _user_credit_model.get_credits(user_id), 0)}
@v1_router.post(
path="/credits", tags=["credits"], dependencies=[Depends(auth_middleware)]
)
async def request_top_up(
request: RequestTopUp, user_id: Annotated[str, Depends(get_user_id)]
):
checkout_url = await _user_credit_model.top_up_intent(user_id, request.amount)
return {"checkout_url": checkout_url}
@v1_router.patch(
path="/credits", tags=["credits"], dependencies=[Depends(auth_middleware)]
)
async def fulfill_checkout(user_id: Annotated[str, Depends(get_user_id)]):
await _user_credit_model.fulfill_checkout(user_id=user_id)
return Response(status_code=200)
@v1_router.post(path="/credits/stripe_webhook", tags=["credits"])
async def stripe_webhook(request: Request):
# Get the raw request body
payload = await request.body()
# Get the signature header
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.secrets.stripe_webhook_secret
)
except ValueError:
# Invalid payload
raise HTTPException(status_code=400)
except stripe.SignatureVerificationError:
# Invalid signature
raise HTTPException(status_code=400)
if (
event["type"] == "checkout.session.completed"
or event["type"] == "checkout.session.async_payment_succeeded"
):
await _user_credit_model.fulfill_checkout(
session_id=event["data"]["object"]["id"]
)
return Response(status_code=200)
########################################################

View File

@ -81,10 +81,14 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
default=True,
description="If authentication is enabled or not",
)
enable_credit: str = Field(
default="false",
enable_credit: bool = Field(
default=False,
description="If user credit system is enabled or not",
)
enable_beta_monthly_credit: bool = Field(
default=True,
description="If beta monthly credits accounting is enabled or not",
)
num_user_credits_refill: int = Field(
default=1500,
description="Number of credits to refill for each user",
@ -309,6 +313,9 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
e2b_api_key: str = Field(default="", description="E2B API key")
nvidia_api_key: str = Field(default="", description="Nvidia API key")
stripe_api_key: str = Field(default="", description="Stripe API Key")
stripe_webhook_secret: str = Field(default="", description="Stripe Webhook Secret")
# Add more secret fields as needed
model_config = SettingsConfigDict(

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "CreditTransaction" ADD COLUMN "runningBalance" INTEGER;

View File

@ -3688,6 +3688,22 @@ docs = ["myst-parser[linkify]", "sphinx", "sphinx-rtd-theme"]
release = ["twine"]
test = ["pylint", "pytest", "pytest-black", "pytest-cov", "pytest-pylint"]
[[package]]
name = "stripe"
version = "11.4.1"
description = "Python bindings for the Stripe API"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "stripe-11.4.1-py2.py3-none-any.whl", hash = "sha256:8aa47a241de0355c383c916c4ef7273ab666f096a44ee7081e357db4a36f0cce"},
{file = "stripe-11.4.1.tar.gz", hash = "sha256:7ddd251b622d490fe57d78487855dc9f4d95b1bb113607e81fd377037a133d5a"},
]
[package.dependencies]
requests = {version = ">=2.20", markers = "python_version >= \"3.0\""}
typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""}
[[package]]
name = "supabase"
version = "2.11.0"
@ -4432,4 +4448,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "711669de9e6d5b81f19286bd41d52f57bc0177ba8ff5f2b477313a5b2d012ae5"
content-hash = "341712d286b6a6fae89055bd21a55d8fa918973e446f6c0f0329a8493022cbae"

View File

@ -39,6 +39,7 @@ python-dotenv = "^1.0.1"
redis = "^5.2.0"
sentry-sdk = "2.19.2"
strenum = "^0.4.9"
stripe = "^11.3.0"
supabase = "2.11.0"
tenacity = "^9.0.0"
tweepy = "^4.14.0"

View File

@ -393,6 +393,8 @@ model CreditTransaction {
amount Int
type CreditTransactionType
runningBalance Int?
isActive Boolean @default(true)
metadata Json?

View File

@ -4,22 +4,27 @@ import pytest
from prisma.models import CreditTransaction
from backend.blocks.llm import AITextGeneratorBlock
from backend.data.credit import UserCredit
from backend.data.credit import BetaUserCredit
from backend.data.user import DEFAULT_USER_ID
from backend.integrations.credentials_store import openai_credentials
from backend.util.test import SpinTestServer
REFILL_VALUE = 1000
user_credit = UserCredit(REFILL_VALUE)
user_credit = BetaUserCredit(REFILL_VALUE)
async def disable_test_user_transactions():
await CreditTransaction.prisma().delete_many(where={"userId": DEFAULT_USER_ID})
@pytest.mark.asyncio(scope="session")
async def test_block_credit_usage(server: SpinTestServer):
current_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
await disable_test_user_transactions()
await user_credit.top_up_credits(DEFAULT_USER_ID, 100)
current_credit = await user_credit.get_credits(DEFAULT_USER_ID)
spending_amount_1 = await user_credit.spend_credits(
DEFAULT_USER_ID,
current_credit,
AITextGeneratorBlock().id,
{
"model": "gpt-4-turbo",
@ -31,68 +36,56 @@ async def test_block_credit_usage(server: SpinTestServer):
},
0.0,
0.0,
validate_balance=False,
)
assert spending_amount_1 > 0
spending_amount_2 = await user_credit.spend_credits(
DEFAULT_USER_ID,
current_credit,
AITextGeneratorBlock().id,
{"model": "gpt-4-turbo", "api_key": "owned_api_key"},
0.0,
0.0,
validate_balance=False,
)
assert spending_amount_2 == 0
new_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
new_credit = await user_credit.get_credits(DEFAULT_USER_ID)
assert new_credit == current_credit - spending_amount_1 - spending_amount_2
@pytest.mark.asyncio(scope="session")
async def test_block_credit_top_up(server: SpinTestServer):
current_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
await disable_test_user_transactions()
current_credit = await user_credit.get_credits(DEFAULT_USER_ID)
await user_credit.top_up_credits(DEFAULT_USER_ID, 100)
new_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
new_credit = await user_credit.get_credits(DEFAULT_USER_ID)
assert new_credit == current_credit + 100
@pytest.mark.asyncio(scope="session")
async def test_block_credit_reset(server: SpinTestServer):
month1 = datetime(2022, 1, 15)
month2 = datetime(2022, 2, 15)
await disable_test_user_transactions()
month1 = 1
month2 = 2
user_credit.time_now = lambda: month2
month2credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
# set the calendar to month 2 but use current time from now
user_credit.time_now = lambda: datetime.now().replace(month=month2)
month2credit = await user_credit.get_credits(DEFAULT_USER_ID)
# Month 1 result should only affect month 1
user_credit.time_now = lambda: month1
month1credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
user_credit.time_now = lambda: datetime.now().replace(month=month1)
month1credit = await user_credit.get_credits(DEFAULT_USER_ID)
await user_credit.top_up_credits(DEFAULT_USER_ID, 100)
assert await user_credit.get_or_refill_credit(DEFAULT_USER_ID) == month1credit + 100
assert await user_credit.get_credits(DEFAULT_USER_ID) == month1credit + 100
# Month 2 balance is unaffected
user_credit.time_now = lambda: month2
assert await user_credit.get_or_refill_credit(DEFAULT_USER_ID) == month2credit
user_credit.time_now = lambda: datetime.now().replace(month=month2)
assert await user_credit.get_credits(DEFAULT_USER_ID) == month2credit
@pytest.mark.asyncio(scope="session")
async def test_credit_refill(server: SpinTestServer):
# Clear all transactions within the month
await CreditTransaction.prisma().update_many(
where={
"userId": DEFAULT_USER_ID,
"createdAt": {
"gte": datetime(2022, 2, 1),
"lt": datetime(2022, 3, 1),
},
},
data={"isActive": False},
)
user_credit.time_now = lambda: datetime(2022, 2, 15)
balance = await user_credit.get_or_refill_credit(DEFAULT_USER_ID)
await disable_test_user_transactions()
balance = await user_credit.get_credits(DEFAULT_USER_ID)
assert balance == REFILL_VALUE

View File

@ -5,6 +5,7 @@ NEXT_PUBLIC_AGPT_MARKETPLACE_URL=http://localhost:8015/api/v1/market
NEXT_PUBLIC_LAUNCHDARKLY_ENABLED=false
NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID=
NEXT_PUBLIC_APP_ENV=dev
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
## Locale settings

View File

@ -45,6 +45,7 @@
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
"@sentry/nextjs": "^8",
"@stripe/stripe-js": "^5.3.0",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.47.8",
"@tanstack/react-table": "^8.20.6",
@ -64,7 +65,7 @@
"launchdarkly-react-client-sdk": "^3.6.0",
"lucide-react": "^0.469.0",
"moment": "^2.30.1",
"next": "^14.2.13",
"next": "^14.2.21",
"next-themes": "^0.4.4",
"react": "^18",
"react-day-picker": "^9.5.0",

View File

@ -44,7 +44,7 @@ export default async function RootLayout({
// enableSystem
disableTransitionOnChange
>
<div className="flex min-h-screen flex-col items-center justify-center">
<div className="flex flex-col items-stretch justify-items-stretch min-h-screen">
<Navbar
links={[
{
@ -102,7 +102,7 @@ export default async function RootLayout({
},
]}
/>
<main className="flex-1">{children}</main>
<main className="flex-grow w-full">{children}</main>
<TallyPopupSimple />
</div>
<Toaster />

View File

@ -93,7 +93,7 @@ export default function LoginPage() {
}
return (
<AuthCard>
<AuthCard className="mx-auto">
<AuthHeader>Login to your account</AuthHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onLogin)}>

View File

@ -73,7 +73,7 @@ const Monitor = () => {
return (
<div
className="grid grid-cols-1 gap-4 md:grid-cols-5 lg:grid-cols-4 xl:grid-cols-10"
className="grid grid-cols-1 gap-4 md:grid-cols-5 lg:grid-cols-4 xl:grid-cols-10 p-4"
data-testid="monitor-page"
>
<AgentFlowList

View File

@ -98,6 +98,7 @@ export default function PrivatePage() {
// This contains ids for built-in "Use Credits for X" credentials
const hiddenCredentials = useMemo(
() => [
"744fdc56-071a-4761-b5a5-0af0ce10a2b5", // Ollama
"fdb7f412-f519-48d1-9b5f-d2f73d0e01fe", // Revid
"760f84fc-b270-42de-91f6-08efe1b512d0", // Ideogram
"6b9fc200-4726-4973-86c9-cd526f5ce5db", // Replicate

View File

@ -84,7 +84,7 @@ export default function SignupPage() {
}
return (
<AuthCard>
<AuthCard className="mx-auto">
<AuthHeader>Create a new account</AuthHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSignup)}>

View File

@ -0,0 +1,73 @@
"use client";
import { Button } from "@/components/agptui/Button";
import useCredits from "@/hooks/useCredits";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
export default function CreditsPage() {
const { credits, requestTopUp } = useCredits();
const [amount, setAmount] = useState(5);
const [patched, setPatched] = useState(false);
const searchParams = useSearchParams();
const topupStatus = searchParams.get("topup");
const api = useBackendAPI();
useEffect(() => {
if (!patched && topupStatus === "success") {
api.fulfillCheckout();
setPatched(true);
}
}, [api, patched, topupStatus]);
return (
<div className="w-full min-w-[800px] px-4 sm:px-8">
<h1 className="font-circular mb-6 text-[28px] font-normal text-neutral-900 dark:text-neutral-100 sm:mb-8 sm:text-[35px]">
Credits
</h1>
<p className="font-circular mb-6 text-base font-normal leading-tight text-neutral-600 dark:text-neutral-400">
Current credits: <b>{credits}</b>
</p>
<h2 className="font-circular mb-4 text-lg font-normal leading-7 text-neutral-700 dark:text-neutral-300">
Top-up Credits
</h2>
<p className="font-circular mb-6 text-base font-normal leading-tight text-neutral-600 dark:text-neutral-400">
{topupStatus === "success" && (
<span className="text-green-500">
Your payment was successful. Your credits will be updated shortly.
</span>
)}
{topupStatus === "cancel" && (
<span className="text-red-500">
Payment failed. Your payment method has not been charged.
</span>
)}
</p>
<div className="w-full">
<label className="font-circular mb-1.5 block text-base font-normal leading-tight text-neutral-700 dark:text-neutral-300">
1 USD = 100 credits, 5 USD is a minimum top-up
</label>
<div className="rounded-[55px] border border-slate-200 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
<input
type="number"
name="displayName"
value={amount}
placeholder="Top-up amount in USD"
min="5"
step="1"
className="font-circular w-full border-none bg-transparent text-base font-normal text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:text-white dark:placeholder:text-neutral-500"
onChange={(e) => setAmount(parseInt(e.target.value))}
/>
</div>
</div>
<Button
type="submit"
variant="default"
className="font-circular mt-4 h-[50px] rounded-[35px] bg-neutral-800 px-6 py-3 text-base font-medium text-white transition-colors hover:bg-neutral-900 dark:bg-neutral-200 dark:text-neutral-900 dark:hover:bg-neutral-100"
onClick={() => requestTopUp(amount)}
>
{"Top-up"}
</Button>
</div>
);
}

View File

@ -33,14 +33,14 @@ export default function Page({}: {}) {
} catch (error) {
console.error("Error fetching submissions:", error);
}
}, [api, supabase]);
}, [api]);
useEffect(() => {
if (!supabase) {
return;
}
fetchData();
}, [supabase]);
}, [supabase, fetchData]);
const onEditSubmission = useCallback((submission: StoreSubmissionRequest) => {
setSubmissionData(submission);
@ -56,7 +56,7 @@ export default function Page({}: {}) {
api.deleteStoreSubmission(submission_id);
fetchData();
},
[supabase],
[api, supabase, fetchData],
);
const onOpenPopout = useCallback(() => {

View File

@ -98,6 +98,7 @@ export default function PrivatePage() {
// This contains ids for built-in "Use Credits for X" credentials
const hiddenCredentials = useMemo(
() => [
"744fdc56-071a-4761-b5a5-0af0ce10a2b5", // Ollama
"fdb7f412-f519-48d1-9b5f-d2f73d0e01fe", // Revid
"760f84fc-b270-42de-91f6-08efe1b512d0", // Ideogram
"6b9fc200-4726-4973-86c9-cd526f5ce5db", // Replicate

View File

@ -7,6 +7,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
links: [
{ text: "Creator Dashboard", href: "/store/dashboard" },
{ text: "Agent dashboard", href: "/store/agent-dashboard" },
{ text: "Credits", href: "/store/credits" },
{ text: "Integrations", href: "/store/integrations" },
{ text: "API Keys", href: "/store/api_keys" },
{ text: "Profile", href: "/store/profile" },

View File

@ -61,7 +61,7 @@ function SearchResults({
};
fetchData();
}, [searchTerm, sort]);
}, [api, searchTerm, sort]);
const agentsCount = agents.length;
const creatorsCount = creators.length;

View File

@ -57,7 +57,7 @@ export const Navbar = async ({ links, menuItemGroups }: NavbarProps) => {
return (
<>
<nav className="sticky top-0 z-50 mx-[16px] hidden h-16 w-full max-w-[1600px] items-center justify-between rounded-bl-2xl rounded-br-2xl border border-white/50 bg-white/5 py-3 pl-6 pr-3 backdrop-blur-[26px] dark:border-gray-700 dark:bg-gray-900 md:inline-flex">
<nav className="sticky top-0 z-50 mx-[16px] hidden h-16 max-w-[1600px] items-center justify-between rounded-bl-2xl rounded-br-2xl border border-white/50 bg-white/5 py-3 pl-6 pr-3 backdrop-blur-[26px] dark:border-gray-700 dark:bg-gray-900 md:inline-flex">
<div className="flex items-center gap-11">
<div className="relative h-10 w-[88.87px]">
<IconAutoGPTLogo className="h-full w-full" />

View File

@ -8,6 +8,7 @@ import {
IconIntegrations,
IconProfile,
IconSliders,
IconCoin,
} from "../ui/icons";
interface SidebarLinkGroup {
@ -22,6 +23,10 @@ interface SidebarProps {
}
export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
const stripeAvailable = Boolean(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
);
return (
<>
<Sheet>
@ -49,6 +54,17 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
Creator dashboard
</div>
</Link>
{stripeAvailable && (
<Link
href="/store/credits"
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"
>
<IconCoin className="h-6 w-6" />
<div className="p-ui-medium text-base font-medium leading-normal">
Credits
</div>
</Link>
)}
<Link
href="/store/integrations"
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"
@ -102,6 +118,17 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
Agent dashboard
</div>
</Link>
{stripeAvailable && (
<Link
href="/store/credits"
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"
>
<IconCoin className="h-6 w-6" />
<div className="p-ui-medium text-base font-medium leading-normal">
Credits
</div>
</Link>
)}
<Link
href="/store/integrations"
className="inline-flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-neutral-800 hover:bg-neutral-800 hover:text-white dark:text-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-white"

View File

@ -1,12 +1,14 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface Props {
children: ReactNode;
className?: string;
}
export default function AuthCard({ children }: Props) {
export default function AuthCard({ children, className }: Props) {
return (
<div className="flex h-[80vh] w-[32rem] items-center justify-center">
<div className={cn("flex h-[80vh] w-[32rem] items-center justify-center", className)}>
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-md">
{children}
</div>

View File

@ -1,37 +1,21 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { IconRefresh } from "@/components/ui/icons";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import useCredits from "@/hooks/useCredits";
export default function CreditButton() {
const [credit, setCredit] = useState<number | null>(null);
const api = useBackendAPI();
const fetchCredit = useCallback(async () => {
try {
const response = await api.getUserCredit();
setCredit(response.credits);
} catch (error) {
console.error("Error fetching credit:", error);
setCredit(null);
}
}, []);
useEffect(() => {
fetchCredit();
}, [fetchCredit]);
const { credits, fetchCredits } = useCredits();
return (
credit !== null && (
credits !== null && (
<Button
onClick={fetchCredit}
onClick={fetchCredits}
variant="outline"
className="flex items-center space-x-2 rounded-xl bg-gray-200"
>
<span className="mr-2 flex items-center text-foreground">
{credit} <span className="ml-2 text-muted-foreground"> credits</span>
{credits} <span className="ml-2 text-muted-foreground"> credits</span>
</span>
<IconRefresh />
</Button>

View File

@ -313,8 +313,6 @@ export const NodeGenericInputField: FC<{
);
}
console.log("propSchema", propSchema);
if ("properties" in propSchema) {
// Render a multi-select for all-boolean sub-schemas with more than 3 properties
if (

View File

@ -323,7 +323,7 @@ export const IconCoin = createIcon((props) => (
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
aria-label="Coin Icon"

View File

@ -874,7 +874,7 @@ export default function useAgentGraph(
request: "save",
state: "saving",
});
}, [saveAgent]);
}, [saveAgent, saveRunRequest.state]);
const requestSaveAndRun = useCallback(() => {
saveAgent();

View File

@ -0,0 +1,48 @@
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { loadStripe } from "@stripe/stripe-js";
import { useRouter } from "next/navigation";
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
export default function useCredits(): {
credits: number | null;
fetchCredits: () => void;
requestTopUp: (usd_amount: number) => Promise<void>;
} {
const [credits, setCredits] = useState<number | null>(null);
const api = useMemo(() => new AutoGPTServerAPI(), []);
const router = useRouter();
const fetchCredits = useCallback(async () => {
const response = await api.getUserCredit();
setCredits(response.credits);
}, [api]);
useEffect(() => {
fetchCredits();
}, [fetchCredits]);
const requestTopUp = useCallback(
async (usd_amount: number) => {
const stripe = await stripePromise;
if (!stripe) {
return;
}
// Convert dollar amount to credit count
const response = await api.requestTopUp(usd_amount * 100);
router.push(response.checkout_url);
},
[api, router],
);
return {
credits,
fetchCredits,
requestTopUp,
};
}

View File

@ -88,6 +88,14 @@ export default class BackendAPI {
}
}
requestTopUp(amount: number): Promise<{ checkout_url: string }> {
return this._request("POST", "/credits", { amount });
}
fulfillCheckout(): Promise<void> {
return this._request("PATCH", "/credits");
}
getBlocks(): Promise<Block[]> {
return this._get("/blocks");
}

View File

@ -42,39 +42,75 @@ test.describe("Build", () => { //(1)!
});
// --8<-- [end:BuildPageExample]
test("user can add all blocks", async ({ page }, testInfo) => {
test("user can add all blocks a-l", async ({ page }, testInfo) => {
// this test is slow af so we 10x the timeout (sorry future me)
await test.setTimeout(testInfo.timeout * 10);
await test.setTimeout(testInfo.timeout * 100);
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
const blocks = await buildPage.getBlocks();
// add all the blocks in order
const blocksToSkip = await buildPage.getBlocksToSkip();
// add all the blocks in order except for the agent executor block
for (const block of blocks) {
if (block.id !== "e189baac-8c20-45a1-94a7-55177ea42565") {
if (block.name[0].toLowerCase() >= "m") {
continue;
}
if (!blocksToSkip.some((b) => b === block.id)) {
await buildPage.addBlock(block);
}
}
await buildPage.closeBlocksPanel();
// check that all the blocks are visible
for (const block of blocks) {
if (block.id !== "e189baac-8c20-45a1-94a7-55177ea42565") {
if (block.name[0].toLowerCase() >= "m") {
continue;
}
if (!blocksToSkip.some((b) => b === block.id)) {
console.log("Checking block:", block.name);
await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy();
}
}
// fill in the input for the agent input block
await buildPage.fillBlockInputByPlaceholder(
blocks.find((b) => b.name === "Agent Input")?.id ?? "",
"Enter Name",
"Agent Input Field",
);
await buildPage.fillBlockInputByPlaceholder(
blocks.find((b) => b.name === "Agent Output")?.id ?? "",
"Enter Name",
"Agent Output Field",
);
// check that we can save the agent with all the blocks
await buildPage.saveAgent("all blocks test", "all blocks test");
// page should have a url like http://localhost:3000/build?flowID=f4f3a1da-cfb3-430f-a074-a455b047e340
await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+"));
});
test("user can add all blocks m-z", async ({ page }, testInfo) => {
// this test is slow af so we 10x the timeout (sorry future me)
await test.setTimeout(testInfo.timeout * 100);
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build"));
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
const blocks = await buildPage.getBlocks();
const blocksToSkip = await buildPage.getBlocksToSkip();
// add all the blocks in order except for the agent executor block
for (const block of blocks) {
if (block.name[0].toLowerCase() < "m") {
continue;
}
if (!blocksToSkip.some((b) => b === block.id)) {
await buildPage.addBlock(block);
}
}
await buildPage.closeBlocksPanel();
// check that all the blocks are visible
for (const block of blocks) {
if (block.name[0].toLowerCase() < "m") {
continue;
}
if (!blocksToSkip.some((b) => b === block.id)) {
await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy();
}
}
// check that we can save the agent with all the blocks
await buildPage.saveAgent("all blocks test", "all blocks test");
// page should have a url like http://localhost:3000/build?flowID=f4f3a1da-cfb3-430f-a074-a455b047e340

View File

@ -6,8 +6,7 @@ import { v4 as uuidv4 } from "uuid";
import * as fs from "fs/promises";
import path from "path";
// --8<-- [start:AttachAgentId]
test.describe.skip("Monitor", () => {
test.describe("Monitor", () => {
let buildPage: BuildPage;
let monitorPage: MonitorPage;
@ -54,21 +53,25 @@ test.describe.skip("Monitor", () => {
await test.expect(agents.length).toBeGreaterThan(0);
});
test("user can export and import agents", async ({
test.skip("user can export and import agents", async ({
page,
}, testInfo: TestInfo) => {
// --8<-- [start:ReadAgentId]
if (testInfo.attachments.length === 0 || !testInfo.attachments[0].body) {
throw new Error("No agent id attached to the test");
}
const id = testInfo.attachments[0].body.toString();
const testAttachName = testInfo.attachments[0].body.toString();
// --8<-- [end:ReadAgentId]
const agents = await monitorPage.listAgents();
const downloadPromise = page.waitForEvent("download");
await monitorPage.exportToFile(
agents.find((a: any) => a.id === id) || agents[0],
const agent = agents.find(
(a: any) => a.name === `test-agent-${testAttachName}`,
);
if (!agent) {
throw new Error(`Agent ${testAttachName} not found`);
}
await monitorPage.exportToFile(agent);
const download = await downloadPromise;
// Wait for the download process to complete and save the downloaded file somewhere.
@ -78,9 +81,6 @@ test.describe.skip("Monitor", () => {
console.log(`downloaded file to ${download.suggestedFilename()}`);
await test.expect(download.suggestedFilename()).toBeDefined();
// test-agent-uuid-v1.json
if (id) {
await test.expect(download.suggestedFilename()).toContain(id);
}
await test.expect(download.suggestedFilename()).toContain("test-agent-");
await test.expect(download.suggestedFilename()).toContain("v1.json");
@ -89,9 +89,9 @@ test.describe.skip("Monitor", () => {
const filesInFolder = await fs.readdir(
`${monitorPage.downloadsFolder}/monitor`,
);
const importFile = filesInFolder.find((f) => f.includes(id));
const importFile = filesInFolder.find((f) => f.includes(testAttachName));
if (!importFile) {
throw new Error(`No import file found for agent ${id}`);
throw new Error(`No import file found for agent ${testAttachName}`);
}
const baseName = importFile.split(".")[0];
await monitorPage.importFromFile(

View File

@ -1,7 +1,7 @@
import { ElementHandle, Locator, Page } from "@playwright/test";
import { BasePage } from "./base.page";
interface Block {
export interface Block {
id: string;
name: string;
description: string;
@ -378,6 +378,39 @@ export class BuildPage extends BasePage {
};
}
async getAgentExecutorBlockDetails(): Promise<Block> {
return {
id: "e189baac-8c20-45a1-94a7-55177ea42565",
name: "Agent Executor",
description: "Executes an existing agent inside your agent",
};
}
async getAgentOutputBlockDetails(): Promise<Block> {
return {
id: "363ae599-353e-4804-937e-b2ee3cef3da4",
name: "Agent Output",
description: "This block is used to output the result of an agent.",
};
}
async getAgentInputBlockDetails(): Promise<Block> {
return {
id: "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b",
name: "Agent Input",
description: "This block is used to provide input to the graph.",
};
}
async getGithubTriggerBlockDetails(): Promise<Block> {
return {
id: "6c60ec01-8128-419e-988f-96a063ee2fea",
name: "Github Trigger",
description:
"This block triggers on pull request events and outputs the event type and payload.",
};
}
async nextTutorialStep(): Promise<void> {
console.log(`clicking next tutorial step`);
await this.page.getByRole("button", { name: "Next" }).click();
@ -448,6 +481,15 @@ export class BuildPage extends BasePage {
);
}
async getBlocksToSkip(): Promise<string[]> {
return [
(await this.getAgentExecutorBlockDetails()).id,
(await this.getAgentInputBlockDetails()).id,
(await this.getAgentOutputBlockDetails()).id,
(await this.getGithubTriggerBlockDetails()).id,
];
}
async waitForRunTutorialButton(): Promise<void> {
console.log(`waiting for run tutorial button`);
await this.page.waitForSelector('[id="press-run-label"]');

View File

@ -43,9 +43,6 @@ export class MonitorPage extends BasePage {
async isLoaded(): Promise<boolean> {
console.log(`checking if monitor page is loaded`);
try {
// Wait for network to settle first
await this.page.waitForLoadState("networkidle", { timeout: 10_000 });
// Wait for the monitor page
await this.page.getByTestId("monitor-page").waitFor({
state: "visible",
@ -55,7 +52,7 @@ export class MonitorPage extends BasePage {
// Wait for table headers to be visible (indicates table structure is ready)
await this.page.locator("thead th").first().waitFor({
state: "visible",
timeout: 5_000,
timeout: 15_000,
});
// Wait for either a table row or an empty tbody to be present
@ -63,14 +60,14 @@ export class MonitorPage extends BasePage {
// Wait for at least one row
this.page.locator("tbody tr[data-testid]").first().waitFor({
state: "visible",
timeout: 5_000,
timeout: 15_000,
}),
// OR wait for an empty tbody (indicating no agents but table is loaded)
this.page
.locator("tbody[data-testid='agent-flow-list-body']:empty")
.waitFor({
state: "visible",
timeout: 5_000,
timeout: 15_000,
}),
]);
@ -114,6 +111,13 @@ export class MonitorPage extends BasePage {
});
}
agents.reduce((acc, agent) => {
if (!agent.id.includes("flow-run")) {
acc.push(agent);
}
return acc;
}, [] as Agent[]);
return agents;
}
@ -219,7 +223,7 @@ export class MonitorPage extends BasePage {
async exportToFile(agent: Agent) {
await this.clickAgent(agent.id);
console.log(`exporting agent ${agent.id} ${agent.name} to file`);
console.log(`exporting agent id: ${agent.id} name: ${agent.name} to file`);
await this.page.getByTestId("export-button").click();
}

View File

@ -1693,10 +1693,10 @@
outvariant "^1.4.3"
strict-event-emitter "^0.5.1"
"@next/env@14.2.20":
version "14.2.20"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.20.tgz#0be2cc955f4eb837516e7d7382284cd5bc1d5a02"
integrity sha512-JfDpuOCB0UBKlEgEy/H6qcBSzHimn/YWjUHzKl1jMeUO+QVRdzmTTl8gFJaNO87c8DXmVKhFCtwxQ9acqB3+Pw==
"@next/env@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.23.tgz#3003b53693cbc476710b856f83e623c8231a6be9"
integrity sha512-CysUC9IO+2Bh0omJ3qrb47S8DtsTKbFidGm6ow4gXIG6reZybqxbkH2nhdEm1tC8SmgzDdpq3BIML0PWsmyUYA==
"@next/eslint-plugin-next@15.1.3":
version "15.1.3"
@ -1705,50 +1705,50 @@
dependencies:
fast-glob "3.3.1"
"@next/swc-darwin-arm64@14.2.20":
version "14.2.20"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.20.tgz#3c99d318c08362aedde5d2778eec3a50b8085d99"
integrity sha512-WDfq7bmROa5cIlk6ZNonNdVhKmbCv38XteVFYsxea1vDJt3SnYGgxLGMTXQNfs5OkFvAhmfKKrwe7Y0Hs+rWOg==
"@next/swc-darwin-arm64@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.23.tgz#6d83f03e35e163e8bbeaf5aeaa6bf55eed23d7a1"
integrity sha512-WhtEntt6NcbABA8ypEoFd3uzq5iAnrl9AnZt9dXdO+PZLACE32z3a3qA5OoV20JrbJfSJ6Sd6EqGZTrlRnGxQQ==
"@next/swc-darwin-x64@14.2.20":
version "14.2.20"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.20.tgz#fd547fad1446a677f29c1160006fdd482bba4052"
integrity sha512-XIQlC+NAmJPfa2hruLvr1H1QJJeqOTDV+v7tl/jIdoFvqhoihvSNykLU/G6NMgoeo+e/H7p/VeWSOvMUHKtTIg==
"@next/swc-darwin-x64@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.23.tgz#e02abc35d5e36ce1550f674f8676999f293ba54f"
integrity sha512-vwLw0HN2gVclT/ikO6EcE+LcIN+0mddJ53yG4eZd0rXkuEr/RnOaMH8wg/sYl5iz5AYYRo/l6XX7FIo6kwbw1Q==
"@next/swc-linux-arm64-gnu@14.2.20":
version "14.2.20"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.20.tgz#1d6ba1929d3a11b74c0185cdeca1e38b824222ca"
integrity sha512-pnzBrHTPXIMm5QX3QC8XeMkpVuoAYOmyfsO4VlPn+0NrHraNuWjdhe+3xLq01xR++iCvX+uoeZmJDKcOxI201Q==
"@next/swc-linux-arm64-gnu@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.23.tgz#f13516ad2d665950951b59e7c239574bb8504d63"
integrity sha512-uuAYwD3At2fu5CH1wD7FpP87mnjAv4+DNvLaR9kiIi8DLStWSW304kF09p1EQfhcbUI1Py2vZlBO2VaVqMRtpg==
"@next/swc-linux-arm64-musl@14.2.20":
version "14.2.20"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.20.tgz#0fe0c67b5d916f99ca76b39416557af609768f17"
integrity sha512-WhJJAFpi6yqmUx1momewSdcm/iRXFQS0HU2qlUGlGE/+98eu7JWLD5AAaP/tkK1mudS/rH2f9E3WCEF2iYDydQ==
"@next/swc-linux-arm64-musl@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.23.tgz#10d05a1c161dc8426d54ccf6d9bbed6953a3252a"
integrity sha512-Mm5KHd7nGgeJ4EETvVgFuqKOyDh+UMXHXxye6wRRFDr4FdVRI6YTxajoV2aHE8jqC14xeAMVZvLqYqS7isHL+g==
"@next/swc-linux-x64-gnu@14.2.20":
version "14.2.20"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.20.tgz#6d29fa8cdb6a9f8250c2048aaa24538f0cd0b02d"
integrity sha512-ao5HCbw9+iG1Kxm8XsGa3X174Ahn17mSYBQlY6VGsdsYDAbz/ZP13wSLfvlYoIDn1Ger6uYA+yt/3Y9KTIupRg==
"@next/swc-linux-x64-gnu@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.23.tgz#7f5856df080f58ba058268b30429a2ab52500536"
integrity sha512-Ybfqlyzm4sMSEQO6lDksggAIxnvWSG2cDWnG2jgd+MLbHYn2pvFA8DQ4pT2Vjk3Cwrv+HIg7vXJ8lCiLz79qoQ==
"@next/swc-linux-x64-musl@14.2.20":
version "14.2.20"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.20.tgz#bfc57482bc033fda8455e8aab1c3cbc44f0c4690"
integrity sha512-CXm/kpnltKTT7945np6Td3w7shj/92TMRPyI/VvveFe8+YE+/YOJ5hyAWK5rpx711XO1jBCgXl211TWaxOtkaA==
"@next/swc-linux-x64-musl@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.23.tgz#d494ebdf26421c91be65f9b1d095df0191c956d8"
integrity sha512-OSQX94sxd1gOUz3jhhdocnKsy4/peG8zV1HVaW6DLEbEmRRtUCUQZcKxUD9atLYa3RZA+YJx+WZdOnTkDuNDNA==
"@next/swc-win32-arm64-msvc@14.2.20":
version "14.2.20"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.20.tgz#6f7783e643310510240a981776532ffe0e02af95"
integrity sha512-upJn2HGQgKNDbXVfIgmqT2BN8f3z/mX8ddoyi1I565FHbfowVK5pnMEwauvLvaJf4iijvuKq3kw/b6E9oIVRWA==
"@next/swc-win32-arm64-msvc@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.23.tgz#62786e7ba4822a20b6666e3e03e5a389b0e7eb3b"
integrity sha512-ezmbgZy++XpIMTcTNd0L4k7+cNI4ET5vMv/oqNfTuSXkZtSA9BURElPFyarjjGtRgZ9/zuKDHoMdZwDZIY3ehQ==
"@next/swc-win32-ia32-msvc@14.2.20":
version "14.2.20"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.20.tgz#58c7720687e80a13795e22c29d5860fa142e44fc"
integrity sha512-igQW/JWciTGJwj3G1ipalD2V20Xfx3ywQy17IV0ciOUBbFhNfyU1DILWsTi32c8KmqgIDviUEulW/yPb2FF90w==
"@next/swc-win32-ia32-msvc@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.23.tgz#ef028af91e1c40a4ebba0d2c47b23c1eeb299594"
integrity sha512-zfHZOGguFCqAJ7zldTKg4tJHPJyJCOFhpoJcVxKL9BSUHScVDnMdDuOU1zPPGdOzr/GWxbhYTjyiEgLEpAoFPA==
"@next/swc-win32-x64-msvc@14.2.20":
version "14.2.20"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.20.tgz#689bc7beb8005b73c95d926e7edfb7f73efc78f2"
integrity sha512-AFmqeLW6LtxeFTuoB+MXFeM5fm5052i3MU6xD0WzJDOwku6SkZaxb1bxjBaRC8uNqTRTSPl0yMFtjNowIVI67w==
"@next/swc-win32-x64-msvc@14.2.23":
version "14.2.23"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.23.tgz#c81838f02f2f16a321b7533890fb63c1edec68e1"
integrity sha512-xCtq5BD553SzOgSZ7UH5LH+OATQihydObTrCTvVzOro8QiWYKdBVwcB2Mn2MLMo6DGW9yH1LSPw7jS7HhgJgjw==
"@next/third-parties@^15.1.3":
version "15.1.3"
@ -3257,6 +3257,11 @@
resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.4.7.tgz#c308f6a883999bd35e87826738ab8a76515932b5"
integrity sha512-99rgLEjf7iwfSEmdqlHkSG3AyLcK0sfExcr0jnc6rLiAkBhzuIsvcHjjUwkR210SOCgXqBPW0ZA6uhnuyppHLw==
"@stripe/stripe-js@^5.3.0":
version "5.4.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-5.4.0.tgz#847e870ddfe9283432526867857a4c1fba9b11ed"
integrity sha512-3tfMbSvLGB+OsJ2MsjWjWo+7sp29dwx+3+9kG/TEnZQJt+EwbF/Nomm43cSK+6oXZA9uhspgyrB+BbrPRumx4g==
"@supabase/auth-js@2.67.3":
version "2.67.3"
resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.67.3.tgz#a1f5eb22440b0cdbf87fe2ecae662a8dd8bb2028"
@ -8976,12 +8981,12 @@ next-themes@^0.4.4:
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.4.4.tgz#ce6f68a4af543821bbc4755b59c0d3ced55c2d13"
integrity sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ==
next@^14.2.13:
version "14.2.20"
resolved "https://registry.yarnpkg.com/next/-/next-14.2.20.tgz#99b551d87ca6505ce63074904cb31a35e21dac9b"
integrity sha512-yPvIiWsiyVYqJlSQxwmzMIReXn5HxFNq4+tlVQ812N1FbvhmE+fDpIAD7bcS2mGYQwPJ5vAsQouyme2eKsxaug==
next@^14.2.21:
version "14.2.23"
resolved "https://registry.yarnpkg.com/next/-/next-14.2.23.tgz#37edc9a4d42c135fd97a4092f829e291e2e7c943"
integrity sha512-mjN3fE6u/tynneLiEg56XnthzuYw+kD7mCujgVqioxyPqbmiotUCGJpIZGS/VaPg3ZDT1tvWxiVyRzeqJFm/kw==
dependencies:
"@next/env" "14.2.20"
"@next/env" "14.2.23"
"@swc/helpers" "0.5.5"
busboy "1.6.0"
caniuse-lite "^1.0.30001579"
@ -8989,15 +8994,15 @@ next@^14.2.13:
postcss "8.4.31"
styled-jsx "5.1.1"
optionalDependencies:
"@next/swc-darwin-arm64" "14.2.20"
"@next/swc-darwin-x64" "14.2.20"
"@next/swc-linux-arm64-gnu" "14.2.20"
"@next/swc-linux-arm64-musl" "14.2.20"
"@next/swc-linux-x64-gnu" "14.2.20"
"@next/swc-linux-x64-musl" "14.2.20"
"@next/swc-win32-arm64-msvc" "14.2.20"
"@next/swc-win32-ia32-msvc" "14.2.20"
"@next/swc-win32-x64-msvc" "14.2.20"
"@next/swc-darwin-arm64" "14.2.23"
"@next/swc-darwin-x64" "14.2.23"
"@next/swc-linux-arm64-gnu" "14.2.23"
"@next/swc-linux-arm64-musl" "14.2.23"
"@next/swc-linux-x64-gnu" "14.2.23"
"@next/swc-linux-x64-musl" "14.2.23"
"@next/swc-win32-arm64-msvc" "14.2.23"
"@next/swc-win32-ia32-msvc" "14.2.23"
"@next/swc-win32-x64-msvc" "14.2.23"
no-case@^3.0.4:
version "3.0.4"