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 REDIS_PASSWORD=password
ENABLE_CREDIT=false ENABLE_CREDIT=false
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
# What environment things should be logged under: local dev or prod # What environment things should be logged under: local dev or prod
APP_ENV=local APP_ENV=local
# What environment to behave as: "local" or "cloud" # 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. ## to use the platform's webhook-related functionality.
## If you are developing locally, you can use something like ngrok to get a publc URL ## If you are developing locally, you can use something like ngrok to get a publc URL
## and tunnel it to your locally running backend. ## and tunnel it to your locally running backend.
PLATFORM_BASE_URL=https://your-public-url-here PLATFORM_BASE_URL=http://localhost:3000
## == INTEGRATION CREDENTIALS == ## ## == INTEGRATION CREDENTIALS == ##
# Each set of server side credentials is required for the corresponding 3rd party # 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 from pydantic import BaseModel

View File

@ -1,30 +1,32 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import datetime, timezone from datetime import datetime, timezone
import stripe
from prisma import Json from prisma import Json
from prisma.enums import CreditTransactionType from prisma.enums import CreditTransactionType
from prisma.errors import UniqueViolationError 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 import Block, BlockInput, get_block
from backend.data.block_cost_config import BLOCK_COSTS from backend.data.block_cost_config import BLOCK_COSTS
from backend.data.cost import BlockCost, BlockCostType 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): class UserCreditBase(ABC):
def __init__(self, num_user_credits_refill: int):
self.num_user_credits_refill = num_user_credits_refill
@abstractmethod @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: Returns:
int: The current credit for the user. int: The current credits for the user.
""" """
pass pass
@ -32,7 +34,6 @@ class UserCreditBase(ABC):
async def spend_credits( async def spend_credits(
self, self,
user_id: str, user_id: str,
user_credit: int,
block_id: str, block_id: str,
input_data: BlockInput, input_data: BlockInput,
data_size: float, data_size: float,
@ -43,7 +44,6 @@ class UserCreditBase(ABC):
Args: Args:
user_id (str): The user ID. user_id (str): The user ID.
user_credit (int): The current credit for the user.
block_id (str): The block ID. block_id (str): The block ID.
input_data (BlockInput): The input data for the block. input_data (BlockInput): The input data for the block.
data_size (float): The size of the data being processed. data_size (float): The size of the data being processed.
@ -57,7 +57,7 @@ class UserCreditBase(ABC):
@abstractmethod @abstractmethod
async def top_up_credits(self, user_id: str, amount: int): 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: Args:
user_id (str): The user ID. user_id (str): The user ID.
@ -65,51 +65,139 @@ class UserCreditBase(ABC):
""" """
pass 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): Args:
async def get_or_refill_credit(self, user_id: str) -> int: user_id (str): The user ID.
cur_time = self.time_now() amount (int): The amount of credits to top up.
cur_month = cur_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
nxt_month = ( Returns:
cur_month.replace(month=cur_month.month + 1) str: The redirect url to the payment page.
if cur_month.month < 12 """
else cur_month.replace(year=cur_month.year + 1, month=1) 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"], by=["userId"],
sum={"amount": True}, sum={"amount": True},
where={ where={
"userId": user_id, "userId": user_id,
"createdAt": {"gte": cur_month, "lt": nxt_month}, "createdAt": {"gte": low_time, "lte": top_time},
"isActive": True, "isActive": True,
}, },
) )
transaction_balance = (
transactions[0].get("_sum", {}).get("amount", 0) if transactions else 0
)
return transaction_balance, datetime.min
if user_credit: async def _enable_transaction(
credit_sum = user_credit[0].get("_sum") or {} self, transaction_key: str, user_id: str, metadata: Json
return credit_sum.get("amount", 0) ):
key = f"MONTHLY-CREDIT-TOP-UP-{cur_month}" transaction = await CreditTransaction.prisma().find_first_or_raise(
where={"transactionKey": transaction_key, "userId": user_id}
)
try: if transaction.isActive:
await CreditTransaction.prisma().create( 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={ data={
"amount": self.num_user_credits_refill, "isActive": True,
"type": CreditTransactionType.TOP_UP, "runningBalance": user_balance + transaction.amount,
"userId": user_id,
"transactionKey": key,
"createdAt": self.time_now(), "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 # Create the transaction
def time_now(): transaction_data: CreditTransactionCreateInput = {
return datetime.now(timezone.utc) "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( def _block_usage_cost(
self, self,
@ -148,8 +236,8 @@ class UserCredit(UserCreditBase):
) -> bool: ) -> bool:
""" """
Filter rules: Filter rules:
- If costFilter is an object, then check if costFilter is the subset of inputValues - If cost_filter is an object, then check if cost_filter is the subset of input_data
- Otherwise, check if costFilter is equal to inputValues. - Otherwise, check if cost_filter is equal to input_data.
- Undefined, null, and empty string are considered as equal. - Undefined, null, and empty string are considered as equal.
""" """
if not isinstance(cost_filter, dict) or not isinstance(input_data, dict): if not isinstance(cost_filter, dict) or not isinstance(input_data, dict):
@ -164,12 +252,10 @@ class UserCredit(UserCreditBase):
async def spend_credits( async def spend_credits(
self, self,
user_id: str, user_id: str,
user_credit: int,
block_id: str, block_id: str,
input_data: BlockInput, input_data: BlockInput,
data_size: float, data_size: float,
run_time: float, run_time: float,
validate_balance: bool = True,
) -> int: ) -> int:
block = get_block(block_id) block = get_block(block_id)
if not block: if not block:
@ -178,42 +264,169 @@ class UserCredit(UserCreditBase):
cost, matching_filter = self._block_usage_cost( cost, matching_filter = self._block_usage_cost(
block=block, input_data=input_data, data_size=data_size, run_time=run_time block=block, input_data=input_data, data_size=data_size, run_time=run_time
) )
if cost <= 0: if cost == 0:
return 0 return 0
if validate_balance and user_credit < cost: await self._add_transaction(
raise ValueError(f"Insufficient credit: {user_credit} < {cost}") user_id=user_id,
amount=-cost,
await CreditTransaction.prisma().create( transaction_type=CreditTransactionType.USAGE,
data={ block_id=block.id,
"userId": user_id, metadata=Json(
"amount": -cost, {
"type": CreditTransactionType.USAGE, "block": block.name,
"blockId": block.id, "input": matching_filter,
"metadata": Json( }
{ ),
"block": block.name,
"input": matching_filter,
}
),
"createdAt": self.time_now(),
}
) )
return cost return cost
async def top_up_credits(self, user_id: str, amount: int): async def top_up_credits(self, user_id: str, amount: int):
await CreditTransaction.prisma().create( if amount < 0:
data={ raise ValueError(f"Top up amount must not be negative: {amount}")
"userId": user_id,
"amount": amount, await self._add_transaction(
"type": CreditTransactionType.TOP_UP, user_id=user_id,
"createdAt": self.time_now(), 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): class DisabledUserCredit(UserCreditBase):
async def get_or_refill_credit(self, *args, **kwargs) -> int: async def get_credits(self, *args, **kwargs) -> int:
return 0 return 0
async def spend_credits(self, *args, **kwargs) -> int: async def spend_credits(self, *args, **kwargs) -> int:
@ -222,12 +435,21 @@ class DisabledUserCredit(UserCreditBase):
async def top_up_credits(self, *args, **kwargs): async def top_up_credits(self, *args, **kwargs):
pass 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: def get_user_credit_model() -> UserCreditBase:
if config.enable_credit.lower() == "true": if not settings.config.enable_credit:
return UserCredit(config.num_user_credits_refill) return DisabledUserCredit()
else:
return DisabledUserCredit(0) 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]]: def get_block_costs() -> dict[str, list[BlockCost]]:

View File

@ -1,5 +1,6 @@
import logging import logging
import os import os
import zlib
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from uuid import uuid4 from uuid import uuid4
@ -54,6 +55,14 @@ async def transaction():
yield tx 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): class BaseDbModel(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4())) id: str = Field(default_factory=lambda: str(uuid4()))

View File

@ -78,12 +78,8 @@ class DatabaseManager(AppService):
# Credits # Credits
user_credit_model = get_user_credit_model() 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( 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), exposed_run_and_wait(user_credit_model.spend_credits),
) )

View File

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

View File

@ -23,6 +23,15 @@ from backend.util.settings import Settings
settings = 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( revid_credentials = APIKeyCredentials(
id="fdb7f412-f519-48d1-9b5f-d2f73d0e01fe", id="fdb7f412-f519-48d1-9b5f-d2f73d0e01fe",
provider="revid", provider="revid",
@ -124,6 +133,7 @@ nvidia_credentials = APIKeyCredentials(
DEFAULT_CREDENTIALS = [ DEFAULT_CREDENTIALS = [
ollama_credentials,
revid_credentials, revid_credentials,
ideogram_credentials, ideogram_credentials,
replicate_credentials, replicate_credentials,
@ -169,6 +179,10 @@ class IntegrationCredentialsStore:
def get_all_creds(self, user_id: str) -> list[Credentials]: def get_all_creds(self, user_id: str) -> list[Credentials]:
users_credentials = self._get_user_integrations(user_id).credentials users_credentials = self._get_user_integrations(user_id).credentials
all_credentials = users_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: if settings.secrets.revid_api_key:
all_credentials.append(revid_credentials) all_credentials.append(revid_credentials)
if settings.secrets.ideogram_api_key: if settings.secrets.ideogram_api_key:

View File

@ -56,3 +56,8 @@ class SetGraphActiveVersion(pydantic.BaseModel):
class UpdatePermissionsRequest(pydantic.BaseModel): class UpdatePermissionsRequest(pydantic.BaseModel):
permissions: List[APIKeyPermission] 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 from typing import TYPE_CHECKING, Annotated, Any, Sequence
import pydantic import pydantic
import stripe
from autogpt_libs.auth.middleware import auth_middleware from autogpt_libs.auth.middleware import auth_middleware
from autogpt_libs.feature_flag.client import feature_flag from autogpt_libs.feature_flag.client import feature_flag
from autogpt_libs.utils.cache import thread_cached 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 from typing_extensions import Optional, TypedDict
import backend.data.block import backend.data.block
@ -40,6 +41,7 @@ from backend.server.model import (
CreateAPIKeyRequest, CreateAPIKeyRequest,
CreateAPIKeyResponse, CreateAPIKeyResponse,
CreateGraph, CreateGraph,
RequestTopUp,
SetGraphActiveVersion, SetGraphActiveVersion,
UpdatePermissionsRequest, UpdatePermissionsRequest,
) )
@ -134,7 +136,54 @@ async def get_user_credits(
user_id: Annotated[str, Depends(get_user_id)], user_id: Annotated[str, Depends(get_user_id)],
) -> dict[str, int]: ) -> dict[str, int]:
# Credits can go negative, so ensure it's at least 0 for user to see. # 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, default=True,
description="If authentication is enabled or not", description="If authentication is enabled or not",
) )
enable_credit: str = Field( enable_credit: bool = Field(
default="false", default=False,
description="If user credit system is enabled or not", 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( num_user_credits_refill: int = Field(
default=1500, default=1500,
description="Number of credits to refill for each user", 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") e2b_api_key: str = Field(default="", description="E2B API key")
nvidia_api_key: str = Field(default="", description="Nvidia 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 # Add more secret fields as needed
model_config = SettingsConfigDict( 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"] release = ["twine"]
test = ["pylint", "pytest", "pytest-black", "pytest-cov", "pytest-pylint"] 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]] [[package]]
name = "supabase" name = "supabase"
version = "2.11.0" version = "2.11.0"
@ -4432,4 +4448,4 @@ type = ["pytest-mypy"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10,<3.13" 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" redis = "^5.2.0"
sentry-sdk = "2.19.2" sentry-sdk = "2.19.2"
strenum = "^0.4.9" strenum = "^0.4.9"
stripe = "^11.3.0"
supabase = "2.11.0" supabase = "2.11.0"
tenacity = "^9.0.0" tenacity = "^9.0.0"
tweepy = "^4.14.0" tweepy = "^4.14.0"

View File

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

View File

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

View File

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

View File

@ -44,7 +44,7 @@ export default async function RootLayout({
// enableSystem // enableSystem
disableTransitionOnChange 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 <Navbar
links={[ 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 /> <TallyPopupSimple />
</div> </div>
<Toaster /> <Toaster />

View File

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

View File

@ -73,7 +73,7 @@ const Monitor = () => {
return ( return (
<div <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" data-testid="monitor-page"
> >
<AgentFlowList <AgentFlowList

View File

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

View File

@ -84,7 +84,7 @@ export default function SignupPage() {
} }
return ( return (
<AuthCard> <AuthCard className="mx-auto">
<AuthHeader>Create a new account</AuthHeader> <AuthHeader>Create a new account</AuthHeader>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSignup)}> <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) { } catch (error) {
console.error("Error fetching submissions:", error); console.error("Error fetching submissions:", error);
} }
}, [api, supabase]); }, [api]);
useEffect(() => { useEffect(() => {
if (!supabase) { if (!supabase) {
return; return;
} }
fetchData(); fetchData();
}, [supabase]); }, [supabase, fetchData]);
const onEditSubmission = useCallback((submission: StoreSubmissionRequest) => { const onEditSubmission = useCallback((submission: StoreSubmissionRequest) => {
setSubmissionData(submission); setSubmissionData(submission);
@ -56,7 +56,7 @@ export default function Page({}: {}) {
api.deleteStoreSubmission(submission_id); api.deleteStoreSubmission(submission_id);
fetchData(); fetchData();
}, },
[supabase], [api, supabase, fetchData],
); );
const onOpenPopout = useCallback(() => { const onOpenPopout = useCallback(() => {

View File

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

View File

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

View File

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

View File

@ -57,7 +57,7 @@ export const Navbar = async ({ links, menuItemGroups }: NavbarProps) => {
return ( 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="flex items-center gap-11">
<div className="relative h-10 w-[88.87px]"> <div className="relative h-10 w-[88.87px]">
<IconAutoGPTLogo className="h-full w-full" /> <IconAutoGPTLogo className="h-full w-full" />

View File

@ -8,6 +8,7 @@ import {
IconIntegrations, IconIntegrations,
IconProfile, IconProfile,
IconSliders, IconSliders,
IconCoin,
} from "../ui/icons"; } from "../ui/icons";
interface SidebarLinkGroup { interface SidebarLinkGroup {
@ -22,6 +23,10 @@ interface SidebarProps {
} }
export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => { export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
const stripeAvailable = Boolean(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
);
return ( return (
<> <>
<Sheet> <Sheet>
@ -49,6 +54,17 @@ export const Sidebar: React.FC<SidebarProps> = ({ linkGroups }) => {
Creator dashboard Creator dashboard
</div> </div>
</Link> </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 <Link
href="/store/integrations" 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" 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 Agent dashboard
</div> </div>
</Link> </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 <Link
href="/store/integrations" 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" 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 { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
className?: string;
} }
export default function AuthCard({ children }: Props) { export default function AuthCard({ children, className }: Props) {
return ( 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"> <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-md">
{children} {children}
</div> </div>

View File

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

View File

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

View File

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

View File

@ -874,7 +874,7 @@ export default function useAgentGraph(
request: "save", request: "save",
state: "saving", state: "saving",
}); });
}, [saveAgent]); }, [saveAgent, saveRunRequest.state]);
const requestSaveAndRun = useCallback(() => { const requestSaveAndRun = useCallback(() => {
saveAgent(); 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[]> { getBlocks(): Promise<Block[]> {
return this._get("/blocks"); return this._get("/blocks");
} }

View File

@ -42,39 +42,75 @@ test.describe("Build", () => { //(1)!
}); });
// --8<-- [end:BuildPageExample] // --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) // 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(buildPage.isLoaded()).resolves.toBeTruthy();
await test.expect(page).toHaveURL(new RegExp("/.*build")); await test.expect(page).toHaveURL(new RegExp("/.*build"));
await buildPage.closeTutorial(); await buildPage.closeTutorial();
await buildPage.openBlocksPanel(); await buildPage.openBlocksPanel();
const blocks = await buildPage.getBlocks(); 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) { 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.addBlock(block);
} }
} }
await buildPage.closeBlocksPanel(); await buildPage.closeBlocksPanel();
// check that all the blocks are visible // check that all the blocks are visible
for (const block of blocks) { 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(); await test.expect(buildPage.hasBlock(block)).resolves.toBeTruthy();
} }
} }
// fill in the input for the agent input block
await buildPage.fillBlockInputByPlaceholder( // check that we can save the agent with all the blocks
blocks.find((b) => b.name === "Agent Input")?.id ?? "", await buildPage.saveAgent("all blocks test", "all blocks test");
"Enter Name", // page should have a url like http://localhost:3000/build?flowID=f4f3a1da-cfb3-430f-a074-a455b047e340
"Agent Input Field", await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+"));
); });
await buildPage.fillBlockInputByPlaceholder(
blocks.find((b) => b.name === "Agent Output")?.id ?? "", test("user can add all blocks m-z", async ({ page }, testInfo) => {
"Enter Name", // this test is slow af so we 10x the timeout (sorry future me)
"Agent Output Field", 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 // check that we can save the agent with all the blocks
await buildPage.saveAgent("all blocks test", "all blocks test"); 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 // 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 * as fs from "fs/promises";
import path from "path"; import path from "path";
// --8<-- [start:AttachAgentId] // --8<-- [start:AttachAgentId]
test.describe("Monitor", () => {
test.describe.skip("Monitor", () => {
let buildPage: BuildPage; let buildPage: BuildPage;
let monitorPage: MonitorPage; let monitorPage: MonitorPage;
@ -54,21 +53,25 @@ test.describe.skip("Monitor", () => {
await test.expect(agents.length).toBeGreaterThan(0); 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, page,
}, testInfo: TestInfo) => { }, testInfo: TestInfo) => {
// --8<-- [start:ReadAgentId] // --8<-- [start:ReadAgentId]
if (testInfo.attachments.length === 0 || !testInfo.attachments[0].body) { if (testInfo.attachments.length === 0 || !testInfo.attachments[0].body) {
throw new Error("No agent id attached to the test"); 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] // --8<-- [end:ReadAgentId]
const agents = await monitorPage.listAgents(); const agents = await monitorPage.listAgents();
const downloadPromise = page.waitForEvent("download"); const downloadPromise = page.waitForEvent("download");
await monitorPage.exportToFile( const agent = agents.find(
agents.find((a: any) => a.id === id) || agents[0], (a: any) => a.name === `test-agent-${testAttachName}`,
); );
if (!agent) {
throw new Error(`Agent ${testAttachName} not found`);
}
await monitorPage.exportToFile(agent);
const download = await downloadPromise; const download = await downloadPromise;
// Wait for the download process to complete and save the downloaded file somewhere. // 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()}`); console.log(`downloaded file to ${download.suggestedFilename()}`);
await test.expect(download.suggestedFilename()).toBeDefined(); await test.expect(download.suggestedFilename()).toBeDefined();
// test-agent-uuid-v1.json // 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("test-agent-");
await test.expect(download.suggestedFilename()).toContain("v1.json"); await test.expect(download.suggestedFilename()).toContain("v1.json");
@ -89,9 +89,9 @@ test.describe.skip("Monitor", () => {
const filesInFolder = await fs.readdir( const filesInFolder = await fs.readdir(
`${monitorPage.downloadsFolder}/monitor`, `${monitorPage.downloadsFolder}/monitor`,
); );
const importFile = filesInFolder.find((f) => f.includes(id)); const importFile = filesInFolder.find((f) => f.includes(testAttachName));
if (!importFile) { 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]; const baseName = importFile.split(".")[0];
await monitorPage.importFromFile( await monitorPage.importFromFile(

View File

@ -1,7 +1,7 @@
import { ElementHandle, Locator, Page } from "@playwright/test"; import { ElementHandle, Locator, Page } from "@playwright/test";
import { BasePage } from "./base.page"; import { BasePage } from "./base.page";
interface Block { export interface Block {
id: string; id: string;
name: string; name: string;
description: 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> { async nextTutorialStep(): Promise<void> {
console.log(`clicking next tutorial step`); console.log(`clicking next tutorial step`);
await this.page.getByRole("button", { name: "Next" }).click(); 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> { async waitForRunTutorialButton(): Promise<void> {
console.log(`waiting for run tutorial button`); console.log(`waiting for run tutorial button`);
await this.page.waitForSelector('[id="press-run-label"]'); await this.page.waitForSelector('[id="press-run-label"]');

View File

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

View File

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