Merge branch 'dev' into ntindle/secrt-1077-add-email-service

This commit is contained in:
Nicholas Tindle
2025-02-19 09:14:46 -06:00
committed by GitHub
34 changed files with 2048 additions and 269 deletions

View File

@@ -5,18 +5,24 @@ from datetime import datetime, timezone
import stripe
from prisma import Json
from prisma.enums import CreditTransactionType
from prisma.enums import CreditRefundRequestStatus, CreditTransactionType
from prisma.errors import UniqueViolationError
from prisma.models import CreditTransaction, User
from prisma.models import CreditRefundRequest, CreditTransaction, User
from prisma.types import CreditTransactionCreateInput, CreditTransactionWhereInput
from pydantic import BaseModel
from tenacity import retry, stop_after_attempt, wait_exponential
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.data.execution import NodeExecutionEntry
from backend.data.model import AutoTopUpConfig, TransactionHistory, UserTransaction
from backend.data.model import (
AutoTopUpConfig,
RefundRequest,
TransactionHistory,
UserTransaction,
)
from backend.data.user import get_user_by_id
from backend.util.settings import Settings
@@ -40,22 +46,37 @@ class UserCreditBase(ABC):
async def get_transaction_history(
self,
user_id: str,
transaction_time: datetime,
transaction_count_limit: int,
transaction_time_ceiling: datetime | None = None,
transaction_type: str | None = None,
) -> TransactionHistory:
"""
Get the credit transactions for the user.
Args:
user_id (str): The user ID.
transaction_time (datetime): The upper bound of the transaction time.
transaction_count_limit (int): The transaction count limit.
transaction_time_ceiling (datetime): The upper bound of the transaction time.
transaction_type (str): The transaction type filter.
Returns:
TransactionHistory: The credit transactions for the user.
"""
pass
@abstractmethod
async def get_refund_requests(self, user_id: str) -> list[RefundRequest]:
"""
Get the refund requests for the user.
Args:
user_id (str): The user ID.
Returns:
list[RefundRequest]: The refund requests for the user.
"""
pass
@abstractmethod
async def spend_credits(
self,
@@ -79,7 +100,7 @@ class UserCreditBase(ABC):
@abstractmethod
async def top_up_credits(self, user_id: str, amount: int):
"""
Top up the credits for the user immediately.
Top up the credits for the user.
Args:
user_id (str): The user ID.
@@ -101,6 +122,46 @@ class UserCreditBase(ABC):
"""
pass
@abstractmethod
async def top_up_refund(
self, user_id: str, transaction_key: str, metadata: dict[str, str]
) -> int:
"""
Refund the top-up transaction for the user.
Args:
user_id (str): The user ID.
transaction_key (str): The top-up transaction key to refund.
metadata (dict[str, str]): The metadata of the refund.
Returns:
int: The amount refunded.
"""
pass
@abstractmethod
async def deduct_credits(
self,
request: stripe.Refund | stripe.Dispute,
):
"""
Deduct the credits for the user based on the dispute or refund of the top-up.
Args:
request (stripe.Refund | stripe.Dispute): The refund or dispute request.
"""
pass
@abstractmethod
async def handle_dispute(self, dispute: stripe.Dispute):
"""
Handle the dispute for the user based on the dispute request.
Args:
dispute (stripe.Dispute): The dispute request.
"""
pass
@abstractmethod
async def fulfill_checkout(
self, *, session_id: str | None = None, user_id: str | None = None
@@ -167,8 +228,17 @@ class UserCreditBase(ABC):
)
return transaction_balance, transaction_time
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, min=1, max=10),
reraise=True,
)
async def _enable_transaction(
self, transaction_key: str, user_id: str, metadata: Json
self,
transaction_key: str,
user_id: str,
metadata: Json,
new_transaction_key: str | None = None,
):
transaction = await CreditTransaction.prisma().find_first_or_raise(
@@ -188,6 +258,7 @@ class UserCreditBase(ABC):
}
},
data={
"transactionKey": new_transaction_key or transaction_key,
"isActive": True,
"runningBalance": user_balance + transaction.amount,
"createdAt": self.time_now(),
@@ -203,6 +274,7 @@ class UserCreditBase(ABC):
is_active: bool = True,
transaction_key: str | None = None,
ceiling_balance: int | None = None,
fail_insufficient_credits: bool = True,
metadata: Json = Json({}),
) -> tuple[int, str]:
"""
@@ -216,6 +288,7 @@ class UserCreditBase(ABC):
is_active (bool): Whether the transaction is active or needs to be manually activated through _enable_transaction.
transaction_key (str | None): The transaction key. Avoids adding transaction if the key already exists.
ceiling_balance (int | None): The ceiling balance. Avoids adding more credits if the balance is already above the ceiling.
fail_insufficient_credits (bool): Whether to fail if the user has insufficient credits.
metadata (Json): The metadata of the transaction.
Returns:
@@ -225,15 +298,17 @@ class UserCreditBase(ABC):
# Get latest balance snapshot
user_balance, _ = await self._get_credits(user_id)
if ceiling_balance and user_balance >= ceiling_balance:
if ceiling_balance and amount > 0 and user_balance >= ceiling_balance:
raise ValueError(
f"You already have enough balance for user {user_id}, balance: {user_balance}, ceiling: {ceiling_balance}"
f"You already have enough balance of ${user_balance/100}, top-up is not required when you already have at least ${ceiling_balance/100}"
)
if amount < 0 and user_balance < abs(amount):
raise ValueError(
f"Insufficient balance of ${user_balance/100} to run the block that costs ${abs(amount)/100}"
)
if amount < 0 and user_balance + amount < 0:
if fail_insufficient_credits:
raise ValueError(
f"Insufficient balance of ${user_balance/100}, where this will cost ${abs(amount)/100}"
)
amount = min(-user_balance, 0)
# Create the transaction
transaction_data: CreditTransactionCreateInput = {
@@ -369,6 +444,153 @@ class UserCredit(UserCreditBase):
async def top_up_credits(self, user_id: str, amount: int):
await self._top_up_credits(user_id, amount)
async def top_up_refund(
self, user_id: str, transaction_key: str, metadata: dict[str, str]
) -> int:
transaction = await CreditTransaction.prisma().find_first_or_raise(
where={
"transactionKey": transaction_key,
"userId": user_id,
"isActive": True,
"type": CreditTransactionType.TOP_UP,
}
)
balance = await self.get_credits(user_id)
amount = transaction.amount
refund_key = f"{transaction.createdAt.strftime('%Y-%W')}-{user_id}"
try:
await CreditRefundRequest.prisma().create(
data={
"id": refund_key,
"transactionKey": transaction_key,
"userId": user_id,
"amount": amount,
"reason": metadata.get("reason", ""),
"status": CreditRefundRequestStatus.PENDING,
"result": "The refund request is under review.",
}
)
except UniqueViolationError:
raise ValueError(
"Unable to request a refund for this transaction, the request of the top-up transaction within the same week has already been made."
)
if amount - balance > settings.config.refund_credit_tolerance_threshold:
# TODO: add a notification for the platform administrator.
return 0 # Register the refund request for manual approval.
# Auto refund the top-up.
refund = stripe.Refund.create(payment_intent=transaction_key, metadata=metadata)
return refund.amount
async def deduct_credits(self, request: stripe.Refund | stripe.Dispute):
if isinstance(request, stripe.Refund) and request.status != "succeeded":
logger.warning(
f"Skip processing refund #{request.id} with status {request.status}"
)
return
if isinstance(request, stripe.Dispute) and request.status != "lost":
logger.warning(
f"Skip processing dispute #{request.id} with status {request.status}"
)
return
transaction = await CreditTransaction.prisma().find_first_or_raise(
where={
"transactionKey": str(request.payment_intent),
"isActive": True,
"type": CreditTransactionType.TOP_UP,
}
)
if request.amount <= 0 or request.amount > transaction.amount:
raise AssertionError(
f"Invalid amount to deduct ${request.amount/100} from ${transaction.amount/100} top-up"
)
await self._add_transaction(
user_id=transaction.userId,
amount=-request.amount,
transaction_type=CreditTransactionType.REFUND,
transaction_key=request.id,
metadata=Json(request),
fail_insufficient_credits=False,
)
# Update the result of the refund request if it exists.
await CreditRefundRequest.prisma().update_many(
where={
"userId": transaction.userId,
"transactionKey": transaction.transactionKey,
},
data={
"amount": request.amount,
"status": CreditRefundRequestStatus.APPROVED,
"result": "The refund request has been approved, the amount will be credited back to your account.",
},
)
async def handle_dispute(self, dispute: stripe.Dispute):
transaction = await CreditTransaction.prisma().find_first_or_raise(
where={
"transactionKey": str(dispute.payment_intent),
"isActive": True,
"type": CreditTransactionType.TOP_UP,
}
)
user_id = transaction.userId
amount = dispute.amount
balance = await self.get_credits(user_id)
# If the user has enough balance, just let them win the dispute.
if balance - amount >= settings.config.refund_credit_tolerance_threshold:
logger.warning(f"Accepting dispute from {user_id} for ${amount/100}")
dispute.close()
return
logger.warning(
f"Adding extra info for dispute from {user_id} for ${amount/100}"
)
# Retrieve recent transaction history to support our evidence.
# This provides a concise timeline that shows service usage and proper credit application.
transaction_history = await self.get_transaction_history(
user_id, transaction_count_limit=None
)
user = await get_user_by_id(user_id)
# Build a comprehensive explanation message that includes:
# - Confirmation that the top-up transaction was processed and credits were applied.
# - A summary of recent transaction history.
# - An explanation that the funds were used to render the agreed service.
evidence_text = (
f"The top-up transaction of ${transaction.amount / 100:.2f} was processed successfully, and the corresponding credits "
"were applied to the users account. Our records confirm that the funds were utilized for the intended services. "
"Below is a summary of recent transaction activity:\n"
)
for tx in transaction_history.transactions:
if tx.transaction_key == transaction.transactionKey:
additional_comment = (
" [This top-up transaction is the subject of the dispute]."
)
else:
additional_comment = ""
evidence_text += (
f"- {tx.description}: Amount ${tx.amount / 100:.2f} on {tx.transaction_time.isoformat()}, "
f"resulting balance ${tx.balance / 100:.2f} {additional_comment}\n"
)
evidence_text += (
"\nThis evidence demonstrates that the transaction was authorized and that the charged amount was used to render the service as agreed."
"\nAdditionally, we provide an automated refund functionality, so the user could have used it if they were not satisfied with the service. "
)
evidence: stripe.Dispute.ModifyParamsEvidence = {
"product_description": "AutoGPT Platform Credits",
"customer_email_address": user.email,
"uncategorized_text": evidence_text[:20000],
}
stripe.Dispute.modify(dispute.id, evidence=evidence)
async def _top_up_credits(
self,
user_id: str,
@@ -386,10 +608,15 @@ class UserCredit(UserCreditBase):
):
raise ValueError(f"Transaction key {key} already exists for user {user_id}")
if amount == 0:
transaction_type = CreditTransactionType.CARD_CHECK
else:
transaction_type = CreditTransactionType.TOP_UP
_, transaction_key = await self._add_transaction(
user_id=user_id,
amount=amount,
transaction_type=CreditTransactionType.TOP_UP,
transaction_type=transaction_type,
is_active=False,
transaction_key=key,
ceiling_balance=ceiling_balance,
@@ -401,8 +628,10 @@ class UserCredit(UserCreditBase):
if not payment_methods:
raise ValueError("No payment method found, please add it on the platform.")
successful_transaction = None
new_transaction_key = None
for payment_method in payment_methods:
if amount == 0:
if transaction_type == CreditTransactionType.CARD_CHECK:
setup_intent = stripe.SetupIntent.create(
customer=customer_id,
usage="off_session",
@@ -414,8 +643,9 @@ class UserCredit(UserCreditBase):
},
)
if setup_intent.status == "succeeded":
return
successful_transaction = Json({"setup_intent": setup_intent})
new_transaction_key = setup_intent.id
break
else:
payment_intent = stripe.PaymentIntent.create(
amount=amount,
@@ -431,15 +661,20 @@ class UserCredit(UserCreditBase):
},
)
if payment_intent.status == "succeeded":
await self._enable_transaction(
transaction_key=transaction_key,
user_id=user_id,
metadata=Json({"payment_intent": payment_intent}),
)
return
successful_transaction = Json({"payment_intent": payment_intent})
new_transaction_key = payment_intent.id
break
raise ValueError(
f"Out of {len(payment_methods)} payment methods tried, none is supported"
if not successful_transaction:
raise ValueError(
f"Out of {len(payment_methods)} payment methods tried, none is supported"
)
await self._enable_transaction(
transaction_key=transaction_key,
new_transaction_key=new_transaction_key,
user_id=user_id,
metadata=successful_transaction,
)
async def top_up_intent(self, user_id: str, amount: int) -> str:
@@ -471,9 +706,9 @@ class UserCredit(UserCreditBase):
payment_intent_data={"setup_future_usage": "off_session"},
saved_payment_method_options={"payment_method_save": "enabled"},
success_url=settings.config.frontend_base_url
+ "/marketplace/credits?topup=success",
+ "/profile/credits?topup=success",
cancel_url=settings.config.frontend_base_url
+ "/marketplace/credits?topup=cancel",
+ "/profile/credits?topup=cancel",
allow_promotion_codes=True,
)
@@ -483,7 +718,7 @@ class UserCredit(UserCreditBase):
transaction_type=CreditTransactionType.TOP_UP,
transaction_key=checkout_session.id,
is_active=False,
metadata=Json({"checkout_session": checkout_session}),
metadata=Json(checkout_session),
)
return checkout_session.url or ""
@@ -499,6 +734,7 @@ class UserCredit(UserCreditBase):
find_filter: CreditTransactionWhereInput = {
"type": CreditTransactionType.TOP_UP,
"isActive": False,
"amount": {"gt": 0},
}
if session_id:
find_filter["transactionKey"] = session_id
@@ -515,18 +751,25 @@ class UserCredit(UserCreditBase):
if not credit_transaction:
return
# If the transaction is not a checkout session, then skip the fulfillment
if not credit_transaction.transactionKey.startswith("cs_"):
return
# Retrieve the Checkout Session from the API
checkout_session = stripe.checkout.Session.retrieve(
credit_transaction.transactionKey
credit_transaction.transactionKey,
expand=["payment_intent"],
)
# 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"]:
assert isinstance(checkout_session.payment_intent, stripe.PaymentIntent)
await self._enable_transaction(
transaction_key=credit_transaction.transactionKey,
new_transaction_key=checkout_session.payment_intent.id,
user_id=credit_transaction.userId,
metadata=Json({"checkout_session": checkout_session}),
metadata=Json(checkout_session),
)
async def get_credits(self, user_id: str) -> int:
@@ -536,15 +779,24 @@ class UserCredit(UserCreditBase):
async def get_transaction_history(
self,
user_id: str,
transaction_time: datetime,
transaction_count_limit: int,
transaction_count_limit: int | None = 100,
transaction_time_ceiling: datetime | None = None,
transaction_type: str | None = None,
) -> TransactionHistory:
transactions_filter: CreditTransactionWhereInput = {
"userId": user_id,
"isActive": True,
}
if transaction_time_ceiling:
transaction_time_ceiling = transaction_time_ceiling.replace(
tzinfo=timezone.utc
)
transactions_filter["createdAt"] = {"lt": transaction_time_ceiling}
if transaction_type:
transactions_filter["type"] = CreditTransactionType[transaction_type]
transactions = await CreditTransaction.prisma().find_many(
where={
"userId": user_id,
"createdAt": {"lt": transaction_time},
"isActive": True,
},
where=transactions_filter,
order={"createdAt": "desc"},
take=transaction_count_limit,
)
@@ -559,7 +811,7 @@ class UserCredit(UserCreditBase):
if t.metadata
else UsageTransactionMetadata()
)
tx_time = t.createdAt.replace(tzinfo=None)
tx_time = t.createdAt.replace(tzinfo=timezone.utc)
if t.type == CreditTransactionType.USAGE and metadata.graph_exec_id:
gt = grouped_transactions[metadata.graph_exec_id]
@@ -573,6 +825,7 @@ class UserCredit(UserCreditBase):
else:
gt = grouped_transactions[t.transactionKey]
gt.description = f"{t.type} Transaction"
gt.transaction_key = t.transactionKey
gt.amount += t.amount
gt.transaction_type = t.type
@@ -588,6 +841,25 @@ class UserCredit(UserCreditBase):
),
)
async def get_refund_requests(self, user_id: str) -> list[RefundRequest]:
return [
RefundRequest(
id=r.id,
user_id=r.userId,
transaction_key=r.transactionKey,
amount=r.amount,
reason=r.reason,
result=r.result,
status=r.status,
created_at=r.createdAt,
updated_at=r.updatedAt,
)
for r in await CreditRefundRequest.prisma().find_many(
where={"userId": user_id},
order={"createdAt": "desc"},
)
]
class BetaUserCredit(UserCredit):
"""
@@ -608,7 +880,7 @@ class BetaUserCredit(UserCredit):
balance, _ = await self._add_transaction(
user_id=user_id,
amount=max(self.num_user_credits_refill - balance, 0),
transaction_type=CreditTransactionType.TOP_UP,
transaction_type=CreditTransactionType.GRANT,
transaction_key=f"MONTHLY-CREDIT-TOP-UP-{cur_time}",
)
return balance
@@ -624,6 +896,9 @@ class DisabledUserCredit(UserCreditBase):
async def get_transaction_history(self, *args, **kwargs) -> TransactionHistory:
return TransactionHistory(transactions=[], next_transaction_time=None)
async def get_refund_requests(self, *args, **kwargs) -> list[RefundRequest]:
return []
async def spend_credits(self, *args, **kwargs) -> int:
return 0
@@ -633,6 +908,15 @@ class DisabledUserCredit(UserCreditBase):
async def top_up_intent(self, *args, **kwargs) -> str:
return ""
async def top_up_refund(self, *args, **kwargs) -> int:
return 0
async def deduct_credits(self, *args, **kwargs):
pass
async def handle_dispute(self, *args, **kwargs):
pass
async def fulfill_checkout(self, *args, **kwargs):
pass
@@ -657,7 +941,11 @@ async def get_stripe_customer_id(user_id: str) -> str:
if user.stripeCustomerId:
return user.stripeCustomerId
customer = stripe.Customer.create(name=user.name or "", email=user.email)
customer = stripe.Customer.create(
name=user.name or "",
email=user.email,
metadata={"user_id": user_id},
)
await User.prisma().update(
where={"id": user_id}, data={"stripeCustomerId": customer.id}
)

View File

@@ -714,9 +714,11 @@ async def fix_llm_provider_credentials():
store = IntegrationCredentialsStore()
broken_nodes = await prisma.get_client().query_raw(
"""
SELECT graph."userId" user_id,
broken_nodes = []
try:
broken_nodes = await prisma.get_client().query_raw(
"""
SELECT graph."userId" user_id,
node.id node_id,
node."constantInput" node_preset_input
FROM platform."AgentNode" node
@@ -725,8 +727,10 @@ async def fix_llm_provider_credentials():
WHERE node."constantInput"::jsonb->'credentials'->>'provider' = 'llm'
ORDER BY graph."userId";
"""
)
logger.info(f"Fixing LLM credential inputs on {len(broken_nodes)} nodes")
)
logger.info(f"Fixing LLM credential inputs on {len(broken_nodes)} nodes")
except Exception as e:
logger.error(f"Error fixing LLM credential inputs: {e}")
user_id: str = ""
user_integrations = None

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import base64
import logging
from datetime import datetime
from datetime import datetime, timezone
from typing import (
TYPE_CHECKING,
Annotated,
@@ -375,7 +375,8 @@ class AutoTopUpConfig(BaseModel):
class UserTransaction(BaseModel):
transaction_time: datetime = datetime.min
transaction_key: str = ""
transaction_time: datetime = datetime.min.replace(tzinfo=timezone.utc)
transaction_type: CreditTransactionType = CreditTransactionType.USAGE
amount: int = 0
balance: int = 0
@@ -383,9 +384,21 @@ class UserTransaction(BaseModel):
usage_graph_id: str | None = None
usage_execution_id: str | None = None
usage_node_count: int = 0
usage_start_time: datetime = datetime.max
usage_start_time: datetime = datetime.max.replace(tzinfo=timezone.utc)
class TransactionHistory(BaseModel):
transactions: list[UserTransaction]
next_transaction_time: datetime | None
class RefundRequest(BaseModel):
id: str
user_id: str
transaction_key: str
amount: int
reason: str
result: str | None = None
status: str
created_at: datetime
updated_at: datetime

View File

@@ -196,16 +196,16 @@ async def get_user_notification_preference(user_id: str) -> NotificationPreferen
# enable notifications by default if user has no notification preference (shouldn't ever happen though)
preferences: dict[NotificationType, bool] = {
NotificationType.AGENT_RUN: user.notifyOnAgentRun or True,
NotificationType.ZERO_BALANCE: user.notifyOnZeroBalance or True,
NotificationType.LOW_BALANCE: user.notifyOnLowBalance or True,
NotificationType.AGENT_RUN: user.notifyOnAgentRun or False,
NotificationType.ZERO_BALANCE: user.notifyOnZeroBalance or False,
NotificationType.LOW_BALANCE: user.notifyOnLowBalance or False,
NotificationType.BLOCK_EXECUTION_FAILED: user.notifyOnBlockExecutionFailed
or True,
or False,
NotificationType.CONTINUOUS_AGENT_ERROR: user.notifyOnContinuousAgentError
or True,
NotificationType.DAILY_SUMMARY: user.notifyOnDailySummary or True,
NotificationType.WEEKLY_SUMMARY: user.notifyOnWeeklySummary or True,
NotificationType.MONTHLY_SUMMARY: user.notifyOnMonthlySummary or True,
or False,
NotificationType.DAILY_SUMMARY: user.notifyOnDailySummary or False,
NotificationType.WEEKLY_SUMMARY: user.notifyOnWeeklySummary or False,
NotificationType.MONTHLY_SUMMARY: user.notifyOnMonthlySummary or False,
}
daily_limit = user.maxEmailsPerDay or 3
notification_preference = NotificationPreference(
@@ -225,6 +225,83 @@ async def get_user_notification_preference(user_id: str) -> NotificationPreferen
) from e
async def update_user_notification_preference(
user_id: str, data: NotificationPreferenceDTO
) -> NotificationPreference:
try:
update_data: UserUpdateInput = {}
if data.email:
update_data["email"] = data.email
if NotificationType.AGENT_RUN in data.preferences:
update_data["notifyOnAgentRun"] = data.preferences[
NotificationType.AGENT_RUN
]
if NotificationType.ZERO_BALANCE in data.preferences:
update_data["notifyOnZeroBalance"] = data.preferences[
NotificationType.ZERO_BALANCE
]
if NotificationType.LOW_BALANCE in data.preferences:
update_data["notifyOnLowBalance"] = data.preferences[
NotificationType.LOW_BALANCE
]
if NotificationType.BLOCK_EXECUTION_FAILED in data.preferences:
update_data["notifyOnBlockExecutionFailed"] = data.preferences[
NotificationType.BLOCK_EXECUTION_FAILED
]
if NotificationType.CONTINUOUS_AGENT_ERROR in data.preferences:
update_data["notifyOnContinuousAgentError"] = data.preferences[
NotificationType.CONTINUOUS_AGENT_ERROR
]
if NotificationType.DAILY_SUMMARY in data.preferences:
update_data["notifyOnDailySummary"] = data.preferences[
NotificationType.DAILY_SUMMARY
]
if NotificationType.WEEKLY_SUMMARY in data.preferences:
update_data["notifyOnWeeklySummary"] = data.preferences[
NotificationType.WEEKLY_SUMMARY
]
if NotificationType.MONTHLY_SUMMARY in data.preferences:
update_data["notifyOnMonthlySummary"] = data.preferences[
NotificationType.MONTHLY_SUMMARY
]
if data.daily_limit:
update_data["maxEmailsPerDay"] = data.daily_limit
user = await User.prisma().update(
where={"id": user_id},
data=update_data,
)
if not user:
raise ValueError(f"User not found with ID: {user_id}")
preferences: dict[NotificationType, bool] = {
NotificationType.AGENT_RUN: user.notifyOnAgentRun or True,
NotificationType.ZERO_BALANCE: user.notifyOnZeroBalance or True,
NotificationType.LOW_BALANCE: user.notifyOnLowBalance or True,
NotificationType.BLOCK_EXECUTION_FAILED: user.notifyOnBlockExecutionFailed
or True,
NotificationType.CONTINUOUS_AGENT_ERROR: user.notifyOnContinuousAgentError
or True,
NotificationType.DAILY_SUMMARY: user.notifyOnDailySummary or True,
NotificationType.WEEKLY_SUMMARY: user.notifyOnWeeklySummary or True,
NotificationType.MONTHLY_SUMMARY: user.notifyOnMonthlySummary or True,
}
notification_preference = NotificationPreference(
user_id=user.id,
email=user.email,
preferences=preferences,
daily_limit=user.maxEmailsPerDay or 3,
# TODO with other changes later, for now we just will email them
emails_sent_today=0,
last_reset_date=datetime.now(),
)
return NotificationPreference.model_validate(notification_preference)
except Exception as e:
raise DatabaseError(
f"Failed to update user notification preference for user {user_id}: {e}"
) from e
async def update_user_notification_preference(
user_id: str, data: NotificationPreferenceDTO
) -> NotificationPreference:

View File

@@ -26,8 +26,8 @@ class TypedPostmarkClient(PostmarkClient):
class Template(BaseModel):
subject: str
body: str
subject_template: str
body_template: str
base_template: str
@@ -41,6 +41,7 @@ class EmailSender:
logger.warning(
"Postmark server API token not found, email sending disabled"
)
self.postmark = None
self.formatter = TextFormatter()
def send_templated(
@@ -58,8 +59,8 @@ class EmailSender:
try:
subject, full_message = self.formatter.format_email(
base_template=template.base_template,
subject_template=template.subject,
content_template=template.body,
subject_template=template.subject_template,
content_template=template.body_template,
data=data,
unsubscribe_link="https://autogpt.com/unsubscribe",
)
@@ -84,12 +85,15 @@ class EmailSender:
with open(pathlib.Path(__file__).parent / template_path, "r") as file:
template = file.read()
return Template(
subject=notification_type_override.subject,
body=template,
subject_template=notification_type_override.subject,
body_template=template,
base_template=base_template,
)
def _send_email(self, user_email: str, subject: str, body: str):
if not self.postmark:
logger.warning("Email tried to send without postmark configured")
return
logger.debug(f"Sending email to {user_email} with subject {subject}")
self.postmark.emails.send(
From=settings.config.postmark_sender_email,

View File

@@ -33,6 +33,7 @@ from backend.data.api_key import (
from backend.data.block import BlockInput, CompletedBlockOutput
from backend.data.credit import (
AutoTopUpConfig,
RefundRequest,
TransactionHistory,
get_auto_top_up,
get_block_costs,
@@ -186,8 +187,7 @@ def execute_graph_block(block_id: str, data: BlockInput) -> CompletedBlockOutput
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_credits(user_id), 0)}
return {"credits": await _user_credit_model.get_credits(user_id)}
@v1_router.post(
@@ -202,6 +202,19 @@ async def request_top_up(
return {"checkout_url": checkout_url}
@v1_router.post(
path="/credits/{transaction_key}/refund",
tags=["credits"],
dependencies=[Depends(auth_middleware)],
)
async def refund_top_up(
user_id: Annotated[str, Depends(get_user_id)],
transaction_key: str,
metadata: dict[str, str],
) -> int:
return await _user_credit_model.top_up_refund(user_id, transaction_key, metadata)
@v1_router.patch(
path="/credits", tags=["credits"], dependencies=[Depends(auth_middleware)]
)
@@ -275,6 +288,12 @@ async def stripe_webhook(request: Request):
session_id=event["data"]["object"]["id"]
)
if event["type"] == "charge.dispute.created":
await _user_credit_model.handle_dispute(event["data"]["object"])
if event["type"] == "refund.created" or event["type"] == "charge.dispute.closed":
await _user_credit_model.deduct_credits(event["data"]["object"])
return Response(status_code=200)
@@ -284,7 +303,7 @@ async def manage_payment_method(
) -> dict[str, str]:
session = stripe.billing_portal.Session.create(
customer=await get_stripe_customer_id(user_id),
return_url=settings.config.frontend_base_url + "/marketplace/credits",
return_url=settings.config.frontend_base_url + "/profile/credits",
)
if not session:
raise HTTPException(
@@ -297,6 +316,7 @@ async def manage_payment_method(
async def get_credit_history(
user_id: Annotated[str, Depends(get_user_id)],
transaction_time: datetime | None = None,
transaction_type: str | None = None,
transaction_count_limit: int = 100,
) -> TransactionHistory:
if transaction_count_limit < 1 or transaction_count_limit > 1000:
@@ -304,11 +324,19 @@ async def get_credit_history(
return await _user_credit_model.get_transaction_history(
user_id=user_id,
transaction_time=transaction_time or datetime.max,
transaction_time_ceiling=transaction_time,
transaction_count_limit=transaction_count_limit,
transaction_type=transaction_type,
)
@v1_router.get(path="/credits/refunds", dependencies=[Depends(auth_middleware)])
async def get_refund_requests(
user_id: Annotated[str, Depends(get_user_id)]
) -> list[RefundRequest]:
return await _user_credit_model.get_refund_requests(user_id)
########################################################
##################### Graphs ###########################
########################################################

View File

@@ -30,5 +30,8 @@ class JSONCryptor:
"""Decrypt string to dictionary"""
if not encrypted_str:
return {}
decrypted = self.fernet.decrypt(encrypted_str.encode())
return json.loads(decrypted.decode())
try:
decrypted = self.fernet.decrypt(encrypted_str.encode())
return json.loads(decrypted.decode())
except Exception:
return {}

View File

@@ -93,7 +93,10 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
default=1500,
description="Number of credits to refill for each user",
)
# Add more configuration fields as needed
refund_credit_tolerance_threshold: int = Field(
default=500,
description="Maximum number of credits above the balance to be auto-approved.",
)
model_config = SettingsConfigDict(
env_file=".env",

View File

@@ -0,0 +1,20 @@
-- CreateEnum
CREATE TYPE "CreditRefundRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
-- CreateTable
CREATE TABLE "CreditRefundRequest" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
"transactionKey" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"result" TEXT,
"status" "CreditRefundRequestStatus" NOT NULL DEFAULT 'PENDING',
CONSTRAINT "CreditRefundRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CreditRefundRequest_userId_transactionKey_idx" ON "CreditRefundRequest"("userId", "transactionKey");

View File

@@ -0,0 +1,11 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "CreditTransactionType" ADD VALUE 'GRANT';
ALTER TYPE "CreditTransactionType" ADD VALUE 'REFUND';
ALTER TYPE "CreditTransactionType" ADD VALUE 'CARD_CHECK';

View File

@@ -0,0 +1,33 @@
-- Create LibraryAgents for all AgentGraphs in their owners' library, skipping existing entries
INSERT INTO "LibraryAgent" (
"id",
"createdAt",
"updatedAt",
"userId",
"agentId",
"agentVersion",
"useGraphIsActiveVersion",
"isFavorite",
"isCreatedByUser",
"isArchived",
"isDeleted")
SELECT
gen_random_uuid(), --> id
ag."createdAt", --> createdAt
ag."createdAt", --> updatedAt
ag."userId", --> userId
ag."id", --> agentId
ag."version", --> agentVersion
true, --> useGraphIsActiveVersion
false, --> isFavorite
true, --> isCreatedByUser
false, --> isArchived
false --> isDeleted
FROM "AgentGraph" AS ag
WHERE ag."isActive" = true
AND NOT EXISTS (
SELECT 1
FROM "LibraryAgent" AS la
WHERE la."userId" = ag."userId"
AND la."agentId" = ag."id"
);

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ aio-pika = "^9.5.4"
anthropic = "^0.45.2"
apscheduler = "^3.11.0"
autogpt-libs = { path = "../autogpt_libs", develop = true }
bleach = "^6.2.0"
click = "^8.1.7"
cryptography = "^43.0"
discord-py = "^2.4.0"
@@ -36,6 +37,7 @@ ollama = "^0.4.1"
openai = "^1.61.1"
pika = "^1.3.2"
pinecone = "^5.3.1"
poetry = "^2.1.1"
postmarker = "^1.0"
praw = "~7.8.1"
prisma = "^0.15.0"
@@ -62,7 +64,6 @@ uvicorn = { extras = ["standard"], version = "^0.34.0" }
websockets = "^13.1"
youtube-transcript-api = "^0.6.2"
# NOTE: please insert new dependencies in their alphabetical location
bleach = "^6.2.0"
[tool.poetry.group.dev.dependencies]
aiohappyeyeballs = "^2.4.4"

View File

@@ -422,16 +422,20 @@ model AnalyticsMetrics {
@@index([userId])
}
enum CreditTransactionType {
TOP_UP
USAGE
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
//////// ACCOUNTING AND CREDIT SYSTEM TABLES //////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
enum CreditTransactionType {
TOP_UP
USAGE
GRANT
REFUND
CARD_CHECK
}
model CreditTransaction {
transactionKey String @default(uuid())
createdAt DateTime @default(now())
@@ -451,6 +455,28 @@ model CreditTransaction {
@@index([userId, createdAt])
}
enum CreditRefundRequestStatus {
PENDING
APPROVED
REJECTED
}
model CreditRefundRequest {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
transactionKey String
amount Int
reason String
result String?
status CreditRefundRequestStatus @default(PENDING)
@@index([userId, transactionKey])
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////// Store TABLES ///////////////////////////

View File

@@ -1,6 +1,7 @@
"use client";
import { useSearchParams } from "next/navigation";
import { GraphID } from "@/lib/autogpt-server-api/types";
import FlowEditor from "@/components/Flow";
export default function Home() {
@@ -9,7 +10,7 @@ export default function Home() {
return (
<FlowEditor
className="flow-container"
flowID={query.get("flowID") ?? undefined}
flowID={query.get("flowID") as GraphID | null ?? undefined}
flowVersion={query.get("flowVersion") ?? undefined}
/>
);

View File

@@ -1,7 +1,11 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { GraphExecution, Schedule, GraphMeta } from "@/lib/autogpt-server-api";
import {
GraphExecution,
Schedule,
LibraryAgent,
} from "@/lib/autogpt-server-api";
import { Card } from "@/components/ui/card";
import {
@@ -15,10 +19,10 @@ import { SchedulesTable } from "@/components/monitor/scheduleTable";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
const Monitor = () => {
const [flows, setFlows] = useState<GraphMeta[]>([]);
const [flows, setFlows] = useState<LibraryAgent[]>([]);
const [executions, setExecutions] = useState<GraphExecution[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [selectedFlow, setSelectedFlow] = useState<GraphMeta | null>(null);
const [selectedFlow, setSelectedFlow] = useState<LibraryAgent | null>(null);
const [selectedRun, setSelectedRun] = useState<GraphExecution | null>(null);
const [sortColumn, setSortColumn] = useState<keyof Schedule>("id");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
@@ -83,7 +87,7 @@ const Monitor = () => {
selectedFlow={selectedFlow}
onSelectFlow={(f) => {
setSelectedRun(null);
setSelectedFlow(f.id == selectedFlow?.id ? null : (f as GraphMeta));
setSelectedFlow(f.id == selectedFlow?.id ? null : f);
}}
/>
<FlowRunsList
@@ -91,7 +95,7 @@ const Monitor = () => {
flows={flows}
executions={[
...(selectedFlow
? executions.filter((v) => v.graph_id == selectedFlow.id)
? executions.filter((v) => v.graph_id == selectedFlow.agent_id)
: executions),
].sort((a, b) => Number(b.started_at) - Number(a.started_at))}
selectedRun={selectedRun}
@@ -102,7 +106,8 @@ const Monitor = () => {
{(selectedRun && (
<FlowRunInfo
flow={
selectedFlow || flows.find((f) => f.id == selectedRun.graph_id)!
selectedFlow ||
flows.find((f) => f.agent_id == selectedRun.graph_id)!
}
execution={selectedRun}
className={column3}
@@ -111,7 +116,9 @@ const Monitor = () => {
(selectedFlow && (
<FlowInfo
flow={selectedFlow}
executions={executions.filter((e) => e.graph_id == selectedFlow.id)}
executions={executions.filter(
(e) => e.graph_id == selectedFlow.agent_id,
)}
className={column3}
refresh={() => {
fetchAgents();

View File

@@ -0,0 +1,149 @@
import { useState } from "react";
import { AlertCircle } from "lucide-react";
import { CreditTransaction } from "@/lib/autogpt-server-api";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
interface RefundModalProps {
isOpen: boolean;
onClose: () => void;
transactions: CreditTransaction[];
formatCredits: (credit: number) => string;
refundCredits: (transaction_key: string, reason: string) => Promise<void>;
}
export const RefundModal = ({
isOpen,
onClose,
transactions,
formatCredits,
refundCredits,
}: RefundModalProps) => {
const [selectedTransactionId, setSelectedTransactionId] =
useState<string>("");
const [refundReason, setRefundReason] = useState("");
const [error, setError] = useState<string | null>(null);
const handleClose = () => {
setSelectedTransactionId("");
setRefundReason("");
setError(null);
onClose();
};
const handleRefundRequest = () => {
setError(null);
const selectedTransaction = transactions.find(
(t) => t.transaction_key === selectedTransactionId,
);
if (!selectedTransaction) {
setError("Please select a transaction to refund");
return;
}
if (refundReason.trim().length < 20) {
setError("Please provide a clear reason for the refund");
return;
}
refundCredits(selectedTransactionId, refundReason).finally(() =>
handleClose(),
);
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Request Refund</DialogTitle>
</DialogHeader>
<div className="py-4">
<div className="space-y-4">
{error && (
<div className="flex items-center gap-2 rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
<p>{error}</p>
</div>
)}
{transactions.length === 0 ? (
<p className="text-sm text-gray-500">
No eligible transactions found for refund.
</p>
) : (
<div className="space-y-2">
<label className="text-sm font-medium">
Select Transaction
</label>
<Select
value={selectedTransactionId}
onValueChange={setSelectedTransactionId}
>
<SelectTrigger>
<SelectValue placeholder="Select a transaction" />
</SelectTrigger>
<SelectContent>
{transactions.map((transaction) => (
<SelectItem
key={transaction.transaction_key}
value={transaction.transaction_key}
>
{new Date(transaction.transaction_time).toLocaleString(
undefined,
{
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
},
)}{" "}
- {formatCredits(transaction.amount)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<label htmlFor="refundReason" className="text-sm font-medium">
Reason for Refund
</label>
<Textarea
id="refundReason"
placeholder="Please explain why you're requesting a refund..."
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
className="min-h-[100px]"
/>
</div>
</div>
</div>
<div className="flex justify-end space-x-4">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleRefundRequest}>Request Refund</Button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,11 +1,14 @@
"use client";
import { useEffect, useCallback } from "react";
import { Button } from "@/components/agptui/Button";
import { useEffect, useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import useCredits from "@/hooks/useCredits";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useSearchParams, useRouter } from "next/navigation";
import { useToast } from "@/components/ui/use-toast";
import { RefundModal } from "./RefundModal";
import { CreditTransaction } from "@/lib/autogpt-server-api";
import {
Table,
TableBody,
@@ -24,7 +27,15 @@ export default function CreditsPage() {
transactionHistory,
fetchTransactionHistory,
formatCredits,
} = useCredits();
refundTopUp,
refundRequests,
fetchRefundRequests,
} = useCredits({
fetchInitialAutoTopUpConfig: true,
fetchInitialRefundRequests: true,
fetchInitialTransactionHistory: true,
fetchTopUpLibrary: true,
});
const router = useRouter();
const searchParams = useSearchParams();
const topupStatus = searchParams.get("topup") as "success" | "cancel" | null;
@@ -32,7 +43,7 @@ export default function CreditsPage() {
const toastOnFail = useCallback(
(action: string, fn: () => Promise<any>) => {
fn().catch((e) => {
return fn().catch((e) => {
toast({
title: `Unable to ${action}`,
description: e.message,
@@ -44,6 +55,33 @@ export default function CreditsPage() {
[toast],
);
const [isRefundModalOpen, setIsRefundModalOpen] = useState(false);
const [topUpTransactions, setTopUpTransactions] = useState<
CreditTransaction[]
>([]);
const openRefundModal = () => {
api.getTransactionHistory(null, 20, "TOP_UP").then((history) => {
setTopUpTransactions(history.transactions);
setIsRefundModalOpen(true);
});
};
const refundCredits = async (transaction_key: string, reason: string) =>
toastOnFail("refund transaction", async () => {
const amount = await refundTopUp(transaction_key, reason);
if (amount > 0) {
toast({
title: "Refund approved! 🎉",
description: `Your refund has been automatically processed. Based on your remaining balance, the amount of ${formatCredits(amount)} will be credited to your account.`,
});
} else {
toast({
title: "Refund Request Received",
description:
"We have received your refund request. A member of our team will review it and reach out via email shortly.",
});
}
});
useEffect(() => {
if (api && topupStatus === "success") {
toastOnFail("fulfill checkout", () => api.fulfillCheckout());
@@ -86,8 +124,8 @@ export default function CreditsPage() {
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
{/* Top-up Form */}
<div>
<h2 className="text-lg">Top-up Credits</h2>
<div className="space-y-4">
<h3 className="text-lg font-medium">Top-up Credits</h3>
<p className="mb-6 text-neutral-600 dark:text-neutral-400">
{topupStatus === "success" && (
@@ -131,7 +169,7 @@ export default function CreditsPage() {
</form>
{/* Auto Top-up Form */}
<form onSubmit={submitAutoTopUpConfig} className="mt-6 space-y-4">
<form onSubmit={submitAutoTopUpConfig} className="my-6 space-y-4">
<h3 className="text-lg font-medium">Automatic Refill Settings</h3>
<div>
@@ -212,16 +250,14 @@ export default function CreditsPage() {
</form>
</div>
<div>
<div className="my-6 space-y-4">
{/* Payment Portal */}
<h2 className="text-lg">Manage Your Payment Methods</h2>
<br />
<h3 className="text-lg font-medium">Manage Your Payment Methods</h3>
<p className="text-neutral-600">
You can manage your cards and see your payment history in the
billing portal.
</p>
<br />
<Button
type="submit"
variant="default"
@@ -232,13 +268,11 @@ export default function CreditsPage() {
</Button>
{/* Transaction History */}
<h2 className="mt-6 text-lg">Transaction History</h2>
<br />
<h3 className="text-lg font-medium">Transaction History</h3>
<p className="text-neutral-600">
Running balance might not be ordered accurately when concurrent
executions are happening.
</p>
<br />
{transactionHistory.transactions.length === 0 && (
<p className="text-neutral-600">No transactions found.</p>
)}
@@ -259,7 +293,16 @@ export default function CreditsPage() {
{transactionHistory.transactions.map((transaction, i) => (
<TableRow key={i}>
<TableCell>
{new Date(transaction.transaction_time).toLocaleString()}
{new Date(transaction.transaction_time).toLocaleString(
undefined,
{
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
},
)}
</TableCell>
<TableCell>{transaction.description}</TableCell>
{/* Make it green if it's positive, red if it's negative */}
@@ -284,6 +327,58 @@ export default function CreditsPage() {
Load More
</Button>
)}
{refundRequests.length > 0 && (
<>
<h3 className="text-lg font-medium">Your Refund Requests</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Last Updated</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>Comment</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{refundRequests.map((request, i) => (
<TableRow key={i}>
<TableCell>
{new Date(request.updated_at).toLocaleString(
undefined,
{
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
},
)}
</TableCell>
<TableCell>{formatCredits(request.amount)}</TableCell>
<TableCell>{request.status}</TableCell>
<TableCell>{request.result}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
<Button
variant="destructive"
onClick={() => openRefundModal()}
className="w-full"
>
Request Refund
</Button>
<RefundModal
isOpen={isRefundModalOpen}
onClose={() => setIsRefundModalOpen(false)}
transactions={topUpTransactions}
formatCredits={formatCredits}
refundCredits={refundCredits}
/>
</div>
</div>
</div>

View File

@@ -37,33 +37,36 @@ export async function updateSettings(formData: FormData) {
}
}
const preferencesError = {};
if (preferencesError) {
throw new SettingsError(
`Failed to update preferences: ${preferencesError.message}`,
);
try {
const api = new BackendApi();
const preferences: NotificationPreferenceDTO = {
email: user?.email || "",
preferences: {
AGENT_RUN: formData.get("notifyOnAgentRun") === "true",
ZERO_BALANCE: formData.get("notifyOnZeroBalance") === "true",
LOW_BALANCE: formData.get("notifyOnLowBalance") === "true",
BLOCK_EXECUTION_FAILED:
formData.get("notifyOnBlockExecutionFailed") === "true",
CONTINUOUS_AGENT_ERROR:
formData.get("notifyOnContinuousAgentError") === "true",
DAILY_SUMMARY: formData.get("notifyOnDailySummary") === "true",
WEEKLY_SUMMARY: formData.get("notifyOnWeeklySummary") === "true",
MONTHLY_SUMMARY: formData.get("notifyOnMonthlySummary") === "true",
},
daily_limit: 0,
};
await api.updateUserPreferences(preferences);
} catch (error) {
console.error(error);
throw new Error(`Failed to update preferences: ${error}`);
}
const api = new BackendApi();
const preferences: NotificationPreferenceDTO = {
email: user?.email || "",
preferences: {
agent_run: formData.get("notifyOnAgentRun") === "true",
zero_balance: formData.get("notifyOnZeroBalance") === "true",
low_balance: formData.get("notifyOnLowBalance") === "true",
block_execution_failed:
formData.get("notifyOnBlockExecutionFailed") === "true",
continuous_agent_error:
formData.get("notifyOnContinuousAgentError") === "true",
daily_summary: formData.get("notifyOnDailySummary") === "true",
weekly_summary: formData.get("notifyOnWeeklySummary") === "true",
monthly_summary: formData.get("notifyOnMonthlySummary") === "true",
},
daily_limit: 0,
};
await api.updateUserPreferences(preferences);
revalidatePath("/profile/settings");
return { success: true };
}
export async function getUserPreferences(): Promise<NotificationPreferenceDTO> {
const api = new BackendApi();
const preferences = await api.getUserPreferences();
return preferences;
}

View File

@@ -3,7 +3,7 @@ import { Metadata } from "next";
import SettingsForm from "@/components/profile/settings/SettingsForm";
import getServerUser from "@/lib/supabase/getServerUser";
import { redirect } from "next/navigation";
import { getUserPreferences } from "./actions";
export const metadata: Metadata = {
title: "Settings",
description: "Manage your account settings and preferences.",
@@ -16,6 +16,8 @@ export default async function SettingsPage() {
redirect("/login");
}
const preferences = await getUserPreferences();
return (
<div className="container max-w-2xl space-y-6 py-10">
<div>
@@ -24,7 +26,7 @@ export default async function SettingsPage() {
Manage your account settings and preferences.
</p>
</div>
<SettingsForm user={user} />
<SettingsForm user={user} preferences={preferences} />
</div>
);
}

View File

@@ -26,7 +26,7 @@ import {
import "@xyflow/react/dist/style.css";
import { CustomNode } from "./CustomNode";
import "./flow.css";
import { BlockUIType, formatEdgeID } from "@/lib/autogpt-server-api";
import { BlockUIType, formatEdgeID, GraphID } from "@/lib/autogpt-server-api";
import { getTypeColor, findNewlyAddedBlockCoordinates } from "@/lib/utils";
import { history } from "./history";
import { CustomEdge } from "./CustomEdge";
@@ -69,7 +69,7 @@ export type NodeDimension = {
export const FlowContext = createContext<FlowContextType | null>(null);
const FlowEditor: React.FC<{
flowID?: string;
flowID?: GraphID;
flowVersion?: string;
className?: string;
}> = ({ flowID, flowVersion, className }) => {

View File

@@ -11,7 +11,9 @@ import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import useCredits from "@/hooks/useCredits";
const CreditsCard = () => {
const { credits, formatCredits, fetchCredits } = useCredits();
const { credits, formatCredits, fetchCredits } = useCredits({
fetchInitialCredits: true,
});
const api = useBackendAPI();
const onRefresh = async () => {

View File

@@ -1,8 +1,5 @@
import BackendAPI, {
GraphExecution,
GraphMeta,
} from "@/lib/autogpt-server-api";
import React, { useMemo } from "react";
import { GraphExecution, LibraryAgent } from "@/lib/autogpt-server-api";
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { TextRenderer } from "@/components/ui/render";
@@ -39,10 +36,10 @@ export const AgentFlowList = ({
onSelectFlow,
className,
}: {
flows: GraphMeta[];
flows: LibraryAgent[];
executions?: GraphExecution[];
selectedFlow: GraphMeta | null;
onSelectFlow: (f: GraphMeta) => void;
selectedFlow: LibraryAgent | null;
onSelectFlow: (f: LibraryAgent) => void;
className?: string;
}) => {
return (
@@ -112,7 +109,7 @@ export const AgentFlowList = ({
lastRun: GraphExecution | null = null;
if (executions) {
const _flowRuns = executions.filter(
(r) => r.graph_id == flow.id,
(r) => r.graph_id == flow.agent_id,
);
runCount = _flowRuns.length;
lastRun =

View File

@@ -2,10 +2,10 @@ import React, { useEffect, useState, useCallback } from "react";
import {
GraphExecution,
Graph,
GraphMeta,
safeCopyGraph,
BlockUIType,
BlockIORootSchema,
LibraryAgent,
} from "@/lib/autogpt-server-api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -39,7 +39,7 @@ import { useBackendAPI } from "@/lib/autogpt-server-api/context";
export const FlowInfo: React.FC<
React.HTMLAttributes<HTMLDivElement> & {
flow: GraphMeta;
flow: LibraryAgent;
executions: GraphExecution[];
flowVersion?: number | "all";
refresh: () => void;
@@ -65,7 +65,7 @@ export const FlowInfo: React.FC<
setNodes,
edges,
setEdges,
} = useAgentGraph(flow.id, flow.version, undefined, false);
} = useAgentGraph(flow.agent_id, flow.agent_version, undefined, false);
const api = useBackendAPI();
const { toast } = useToast();
@@ -76,7 +76,8 @@ export const FlowInfo: React.FC<
);
const selectedFlowVersion: Graph | undefined = flowVersions?.find(
(v) =>
v.version == (selectedVersion == "all" ? flow.version : selectedVersion),
v.version ==
(selectedVersion == "all" ? flow.agent_version : selectedVersion),
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -139,8 +140,10 @@ export const FlowInfo: React.FC<
};
useEffect(() => {
api.getGraphAllVersions(flow.id).then((result) => setFlowVersions(result));
}, [flow.id, api]);
api
.getGraphAllVersions(flow.agent_id)
.then((result) => setFlowVersions(result));
}, [flow.agent_id, api]);
const openRunnerInput = () => setIsRunnerInputOpen(true);
@@ -181,7 +184,8 @@ export const FlowInfo: React.FC<
<CardHeader className="flex-row justify-between space-x-3 space-y-0">
<div>
<CardTitle>
{flow.name} <span className="font-light">v{flow.version}</span>
{flow.name}{" "}
<span className="font-light">v{flow.agent_version}</span>
</CardTitle>
</div>
<div className="flex items-start space-x-2">
@@ -224,7 +228,7 @@ export const FlowInfo: React.FC<
)}
<Link
className={buttonVariants({ variant: "default" })}
href={`/build?flowID=${flow.id}&flowVersion=${flow.version}`}
href={`/build?flowID=${flow.agent_id}&flowVersion=${flow.agent_version}`}
>
<Pencil2Icon className="mr-2" />
Open in Builder
@@ -268,10 +272,10 @@ export const FlowInfo: React.FC<
</CardHeader>
<CardContent>
<FlowRunsStats
flows={[selectedFlowVersion ?? flow]}
flows={[flow]}
executions={executions.filter(
(execution) =>
execution.graph_id == flow.id &&
execution.graph_id == flow.agent_id &&
(selectedVersion == "all" ||
execution.graph_version == selectedVersion),
)}
@@ -296,7 +300,7 @@ export const FlowInfo: React.FC<
<Button
variant="destructive"
onClick={() => {
api.deleteGraph(flow.id).then(() => {
api.deleteGraph(flow.agent_id).then(() => {
setIsDeleteModalOpen(false);
refresh();
});

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from "react";
import {
GraphExecution,
GraphMeta,
LibraryAgent,
NodeExecutionResult,
SpecialBlockID,
} from "@/lib/autogpt-server-api";
@@ -17,7 +17,7 @@ import { useBackendAPI } from "@/lib/autogpt-server-api/context";
export const FlowRunInfo: React.FC<
React.HTMLAttributes<HTMLDivElement> & {
flow: GraphMeta;
flow: LibraryAgent;
execution: GraphExecution;
}
> = ({ flow, execution, ...props }) => {
@@ -27,7 +27,7 @@ export const FlowRunInfo: React.FC<
const fetchBlockResults = useCallback(async () => {
const executionResults = await api.getGraphExecutionInfo(
flow.id,
flow.agent_id,
execution.execution_id,
);
@@ -70,7 +70,7 @@ export const FlowRunInfo: React.FC<
result: result.output_data?.output || undefined,
})),
);
}, [api, flow.id, execution.execution_id]);
}, [api, flow.agent_id, execution.execution_id]);
// Fetch graph and execution data
useEffect(() => {
@@ -78,15 +78,15 @@ export const FlowRunInfo: React.FC<
fetchBlockResults();
}, [isOutputOpen, fetchBlockResults]);
if (execution.graph_id != flow.id) {
if (execution.graph_id != flow.agent_id) {
throw new Error(
`FlowRunInfo can't be used with non-matching execution.graph_id and flow.id`,
);
}
const handleStopRun = useCallback(() => {
api.stopGraphExecution(flow.id, execution.execution_id);
}, [api, flow.id, execution.execution_id]);
api.stopGraphExecution(flow.agent_id, execution.execution_id);
}, [api, flow.agent_id, execution.execution_id]);
return (
<>
@@ -107,17 +107,19 @@ export const FlowRunInfo: React.FC<
<Button onClick={() => setIsOutputOpen(true)} variant="outline">
<ExitIcon className="mr-2" /> View Outputs
</Button>
<Link
className={buttonVariants({ variant: "default" })}
href={`/build?flowID=${flow.id}&flowVersion=${execution.graph_version}&flowExecutionID=${execution.execution_id}`}
>
<Pencil2Icon className="mr-2" /> Open in Builder
</Link>
{flow.is_created_by_user && (
<Link
className={buttonVariants({ variant: "default" })}
href={`/build?flowID=${execution.graph_id}&flowVersion=${execution.graph_version}&flowExecutionID=${execution.execution_id}`}
>
<Pencil2Icon className="mr-2" /> Open in Builder
</Link>
)}
</div>
</CardHeader>
<CardContent>
<p className="hidden">
<strong>Agent ID:</strong> <code>{flow.id}</code>
<strong>Agent ID:</strong> <code>{flow.agent_id}</code>
</p>
<p className="hidden">
<strong>Run ID:</strong> <code>{execution.execution_id}</code>

View File

@@ -1,5 +1,5 @@
import React from "react";
import { GraphExecution, GraphMeta } from "@/lib/autogpt-server-api";
import { GraphExecution, LibraryAgent } from "@/lib/autogpt-server-api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
@@ -14,7 +14,7 @@ import { FlowRunStatusBadge } from "@/components/monitor/FlowRunStatusBadge";
import { TextRenderer } from "../ui/render";
export const FlowRunsList: React.FC<{
flows: GraphMeta[];
flows: LibraryAgent[];
executions: GraphExecution[];
className?: string;
selectedRun?: GraphExecution | null;
@@ -51,7 +51,9 @@ export const FlowRunsList: React.FC<{
>
<TableCell>
<TextRenderer
value={flows.find((f) => f.id == execution.graph_id)?.name}
value={
flows.find((f) => f.agent_id == execution.graph_id)?.name
}
truncateLengthLimit={30}
/>
</TableCell>

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { GraphExecution, GraphMeta } from "@/lib/autogpt-server-api";
import { GraphExecution, LibraryAgent } from "@/lib/autogpt-server-api";
import { CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
@@ -11,7 +11,7 @@ import { Calendar } from "@/components/ui/calendar";
import { FlowRunsTimeline } from "@/components/monitor/FlowRunsTimeline";
export const FlowRunsStatus: React.FC<{
flows: GraphMeta[];
flows: LibraryAgent[];
executions: GraphExecution[];
title?: string;
className?: string;

View File

@@ -1,4 +1,4 @@
import { GraphExecution, GraphMeta } from "@/lib/autogpt-server-api";
import { GraphExecution, LibraryAgent } from "@/lib/autogpt-server-api";
import {
ComposedChart,
DefaultLegendContentProps,
@@ -22,7 +22,7 @@ export const FlowRunsTimeline = ({
dataMin,
className,
}: {
flows: GraphMeta[];
flows: LibraryAgent[];
executions: GraphExecution[];
dataMin: "dataMin" | number;
className?: string;
@@ -62,7 +62,7 @@ export const FlowRunsTimeline = ({
if (payload && payload.length) {
const data: GraphExecution & { time: number; _duration: number } =
payload[0].payload;
const flow = flows.find((f) => f.id === data.graph_id);
const flow = flows.find((f) => f.agent_id === data.graph_id);
return (
<Card className="p-2 text-xs leading-normal">
<p>
@@ -94,7 +94,7 @@ export const FlowRunsTimeline = ({
<Scatter
key={flow.id}
data={executions
.filter((e) => e.graph_id == flow.id)
.filter((e) => e.graph_id == flow.agent_id)
.map((e) => ({
...e,
time: e.started_at + e.total_run_time * 1000,

View File

@@ -1,4 +1,4 @@
import { Schedule } from "@/lib/autogpt-server-api";
import { LibraryAgent, Schedule } from "@/lib/autogpt-server-api";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
@@ -21,7 +21,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GraphMeta } from "@/lib/autogpt-server-api";
import { useRouter } from "next/navigation";
import { useState } from "react";
import {
@@ -36,7 +35,7 @@ import { Label } from "../ui/label";
interface SchedulesTableProps {
schedules: Schedule[];
agents: GraphMeta[];
agents: LibraryAgent[];
onRemoveSchedule: (scheduleId: string, enabled: boolean) => void;
sortColumn: keyof Schedule;
sortDirection: "asc" | "desc";
@@ -54,12 +53,12 @@ export const SchedulesTable = ({
const { toast } = useToast();
const router = useRouter();
const cron_manager = new CronExpressionManager();
const [selectedAgent, setSelectedAgent] = useState<string>("");
const [selectedVersion, setSelectedVersion] = useState<number>(0);
const [selectedAgent, setSelectedAgent] = useState<string>(""); // Library Agent ID
const [selectedVersion, setSelectedVersion] = useState<number>(0); // Graph version
const [maxVersion, setMaxVersion] = useState<number>(0);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<string>("");
const [selectedFilter, setSelectedFilter] = useState<string>(""); // Graph ID
const filteredAndSortedSchedules = [...schedules]
.filter(
@@ -91,8 +90,8 @@ export const SchedulesTable = ({
const handleAgentSelect = (agentId: string) => {
setSelectedAgent(agentId);
const agent = agents.find((a) => a.id === agentId);
setMaxVersion(agent!.version);
setSelectedVersion(agent!.version);
setMaxVersion(agent!.agent_version);
setSelectedVersion(agent!.agent_version);
};
const handleVersionSelect = (version: string) => {
@@ -117,10 +116,11 @@ export const SchedulesTable = ({
return;
}
setIsLoading(true);
const agent = agents.find((a) => a.id == selectedAgent)!;
try {
await new Promise((resolve) => setTimeout(resolve, 100));
router.push(
`/build?flowID=${selectedAgent}&flowVersion=${selectedVersion}&open_scheduling=true`,
`/build?flowID=${agent.agent_id}&flowVersion=${agent.agent_version}&open_scheduling=true`,
);
} catch (error) {
console.error("Navigation error:", error);
@@ -184,7 +184,7 @@ export const SchedulesTable = ({
</SelectTrigger>
<SelectContent className="text-xs">
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<SelectItem key={agent.id} value={agent.agent_id}>
{agent.name}
</SelectItem>
))}
@@ -237,8 +237,8 @@ export const SchedulesTable = ({
filteredAndSortedSchedules.map((schedule) => (
<TableRow key={schedule.id}>
<TableCell className="font-medium">
{agents.find((a) => a.id === schedule.graph_id)?.name ||
schedule.graph_id}
{agents.find((a) => a.agent_id === schedule.graph_id)
?.name || schedule.graph_id}
</TableCell>
<TableCell>{schedule.graph_version}</TableCell>
<TableCell>

View File

@@ -20,6 +20,10 @@ import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { updateSettings } from "@/app/profile/(user)/settings/actions";
import { toast } from "@/components/ui/use-toast";
import {
NotificationPreference,
NotificationPreferenceDTO,
} from "@/lib/autogpt-server-api";
const formSchema = z
.object({
@@ -57,21 +61,24 @@ const formSchema = z
interface SettingsFormProps {
user: User;
preferences: NotificationPreferenceDTO;
}
export default function SettingsForm({ user }: SettingsFormProps) {
export default function SettingsForm({ user, preferences }: SettingsFormProps) {
const defaultValues = {
email: user.email || "",
password: "",
confirmPassword: "",
notifyOnAgentRun: true,
notifyOnZeroBalance: true,
notifyOnLowBalance: true,
notifyOnBlockExecutionFailed: true,
notifyOnContinuousAgentError: true,
notifyOnDailySummary: true,
notifyOnWeeklySummary: true,
notifyOnMonthlySummary: true,
notifyOnAgentRun: preferences.preferences.AGENT_RUN,
notifyOnZeroBalance: preferences.preferences.ZERO_BALANCE,
notifyOnLowBalance: preferences.preferences.LOW_BALANCE,
notifyOnBlockExecutionFailed:
preferences.preferences.BLOCK_EXECUTION_FAILED,
notifyOnContinuousAgentError:
preferences.preferences.CONTINUOUS_AGENT_ERROR,
notifyOnDailySummary: preferences.preferences.DAILY_SUMMARY,
notifyOnWeeklySummary: preferences.preferences.WEEKLY_SUMMARY,
notifyOnMonthlySummary: preferences.preferences.MONTHLY_SUMMARY,
};
const form = useForm<z.infer<typeof formSchema>>({
@@ -94,8 +101,6 @@ export default function SettingsForm({ user }: SettingsFormProps) {
toast({
title: "Successfully updated settings",
});
form.reset(defaultValues);
} catch (error) {
toast({
title: "Error",

View File

@@ -6,6 +6,7 @@ import BackendAPI, {
BlockUIType,
formatEdgeID,
Graph,
GraphID,
NodeExecutionResult,
} from "@/lib/autogpt-server-api";
import {
@@ -21,13 +22,12 @@ import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useToast } from "@/components/ui/use-toast";
import { InputItem } from "@/components/RunnerUIWrapper";
import { GraphMeta } from "@/lib/autogpt-server-api";
import useCredits from "./useCredits";
import { default as NextLink } from "next/link";
const ajv = new Ajv({ strict: false, allErrors: true });
export default function useAgentGraph(
flowID?: string,
flowID?: GraphID,
flowVersion?: number,
flowExecutionID?: string,
passDataToBeads?: boolean,
@@ -76,7 +76,6 @@ export default function useAgentGraph(
useState(false);
const [nodes, setNodes] = useState<CustomNode[]>([]);
const [edges, setEdges] = useState<CustomEdge[]>([]);
const { credits, fetchCredits } = useCredits();
const api = useMemo(
() => new BackendAPI(process.env.NEXT_PUBLIC_AGPT_SERVER_URL!),

View File

@@ -1,18 +1,36 @@
import AutoGPTServerAPI from "@/lib/autogpt-server-api";
import { TransactionHistory } from "@/lib/autogpt-server-api/types";
import {
RefundRequest,
TransactionHistory,
} from "@/lib/autogpt-server-api/types";
import { useCallback, useEffect, useMemo, useState } from "react";
import { loadStripe, Stripe } from "@stripe/stripe-js";
import { useRouter } from "next/navigation";
export default function useCredits(): {
export default function useCredits({
fetchInitialCredits = false,
fetchInitialAutoTopUpConfig = false,
fetchInitialTransactionHistory = false,
fetchInitialRefundRequests = false,
fetchTopUpLibrary = false,
}: {
fetchInitialCredits?: boolean;
fetchInitialAutoTopUpConfig?: boolean;
fetchInitialTransactionHistory?: boolean;
fetchInitialRefundRequests?: boolean;
fetchTopUpLibrary?: boolean;
} = {}): {
credits: number | null;
fetchCredits: () => void;
requestTopUp: (credit_amount: number) => Promise<void>;
refundTopUp: (transaction_key: string, reason: string) => Promise<number>;
autoTopUpConfig: { amount: number; threshold: number } | null;
fetchAutoTopUpConfig: () => void;
updateAutoTopUpConfig: (amount: number, threshold: number) => Promise<void>;
transactionHistory: TransactionHistory;
fetchTransactionHistory: () => void;
refundRequests: RefundRequest[];
fetchRefundRequests: () => void;
formatCredits: (credit: number | null) => string;
} {
const [credits, setCredits] = useState<number | null>(null);
@@ -31,10 +49,12 @@ export default function useCredits(): {
}, [api]);
useEffect(() => {
if (!fetchInitialCredits) return;
fetchCredits();
}, [fetchCredits]);
}, [fetchCredits, fetchInitialCredits]);
useEffect(() => {
if (!fetchTopUpLibrary) return;
const fetchStripe = async () => {
if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY?.trim()) {
console.debug("Stripe publishable key is not set.");
@@ -46,7 +66,7 @@ export default function useCredits(): {
setStripe(stripe);
};
fetchStripe();
}, []);
}, [fetchTopUpLibrary]);
const fetchAutoTopUpConfig = useCallback(async () => {
const response = await api.getAutoTopUpConfig();
@@ -54,8 +74,9 @@ export default function useCredits(): {
}, [api]);
useEffect(() => {
if (!fetchInitialAutoTopUpConfig) return;
fetchAutoTopUpConfig();
}, [fetchAutoTopUpConfig]);
}, [fetchAutoTopUpConfig, fetchInitialAutoTopUpConfig]);
const updateAutoTopUpConfig = useCallback(
async (amount: number, threshold: number) => {
@@ -81,6 +102,16 @@ export default function useCredits(): {
[api, router, stripe],
);
const refundTopUp = useCallback(
async (transaction_key: string, reason: string) => {
const refunded_amount = await api.refundTopUp(transaction_key, reason);
await fetchCredits();
setTransactionHistory(await api.getTransactionHistory());
return refunded_amount;
},
[api, fetchCredits],
);
const [transactionHistory, setTransactionHistory] =
useState<TransactionHistory>({
transactions: [],
@@ -102,11 +133,24 @@ export default function useCredits(): {
}, [api, transactionHistory]);
useEffect(() => {
if (!fetchInitialTransactionHistory) return;
fetchTransactionHistory();
// Note: We only need to fetch transaction history once.
// Hence, we should avoid `fetchTransactionHistory` to the dependency array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [fetchInitialTransactionHistory]);
const [refundRequests, setRefundRequests] = useState<RefundRequest[]>([]);
const fetchRefundRequests = useCallback(async () => {
const response = await api.getRefundRequests();
setRefundRequests(response);
}, [api]);
useEffect(() => {
if (!fetchInitialRefundRequests) return;
fetchRefundRequests();
}, [fetchRefundRequests, fetchInitialRefundRequests]);
const formatCredits = useCallback((credit: number | null) => {
if (credit === null) {
@@ -123,11 +167,14 @@ export default function useCredits(): {
credits,
fetchCredits,
requestTopUp,
refundTopUp,
autoTopUpConfig,
fetchAutoTopUpConfig,
updateAutoTopUpConfig,
transactionHistory,
fetchTransactionHistory,
refundRequests,
fetchRefundRequests,
formatCredits,
};
}

View File

@@ -18,6 +18,7 @@ import {
GraphCreatable,
GraphMeta,
GraphUpdateable,
LibraryAgent,
MyAgentsResponse,
NodeExecutionResult,
ProfileDetails,
@@ -35,9 +36,11 @@ import {
NotificationPreferenceDTO,
UserPasswordCredentials,
NotificationPreference,
RefundRequest,
} from "./types";
import { createBrowserClient } from "@supabase/ssr";
import getServerSupabase from "../supabase/getServerSupabase";
import { filter } from "../../../test-runner-jest.config";
const isClient = typeof window !== "undefined";
@@ -95,7 +98,7 @@ export default class BackendAPI {
}
}
getUserPreferences(): Promise<NotificationPreference> {
getUserPreferences(): Promise<NotificationPreferenceDTO> {
return this._get("/auth/user/preferences");
}
@@ -117,26 +120,31 @@ export default class BackendAPI {
}
getTransactionHistory(
lastTransction: Date | null,
countLimit: number,
lastTransction: Date | null = null,
countLimit: number | null = null,
transactionType: string | null = null,
): Promise<TransactionHistory> {
return this._get(
`/credits/transactions`,
lastTransction
? {
transaction_time: lastTransction,
transaction_count_limit: countLimit,
}
: {
transaction_count_limit: countLimit,
},
);
const filters: Record<string, any> = {};
if (lastTransction) filters.transaction_time = lastTransction;
if (countLimit) filters.transaction_count_limit = countLimit;
if (transactionType) filters.transaction_type = transactionType;
return this._get(`/credits/transactions`, filters);
}
getRefundRequests(): Promise<RefundRequest[]> {
return this._get(`/credits/refunds`);
}
requestTopUp(credit_amount: number): Promise<{ checkout_url: string }> {
return this._request("POST", "/credits", { credit_amount });
}
refundTopUp(transaction_key: string, reason: string): Promise<number> {
return this._request("POST", `/credits/${transaction_key}/refund`, {
reason,
});
}
getUserPaymentPortalLink(): Promise<{ url: string }> {
return this._get("/credits/manage");
}
@@ -459,7 +467,7 @@ export default class BackendAPI {
/////////// V2 LIBRARY API //////////////
/////////////////////////////////////////
async listLibraryAgents(): Promise<GraphMeta[]> {
async listLibraryAgents(): Promise<LibraryAgent[]> {
return this._get("/library/agents");
}

View File

@@ -224,12 +224,12 @@ export type GraphExecution = {
duration: number;
total_run_time: number;
status: "QUEUED" | "RUNNING" | "COMPLETED" | "TERMINATED" | "FAILED";
graph_id: string;
graph_id: GraphID;
graph_version: number;
};
export type GraphMeta = {
id: string;
id: GraphID;
version: number;
is_active: boolean;
name: string;
@@ -238,6 +238,8 @@ export type GraphMeta = {
output_schema: BlockIOObjectSubSchema;
};
export type GraphID = Brand<string, "GraphID">;
/* Mirror of backend/data/graph.py:Graph */
export type Graph = GraphMeta & {
nodes: Array<Node>;
@@ -259,7 +261,7 @@ export type GraphCreatable = Omit<GraphUpdateable, "id"> & { id?: string };
/* Mirror of backend/data/execution.py:ExecutionResult */
export type NodeExecutionResult = {
graph_id: string;
graph_id: GraphID;
graph_version: number;
graph_exec_id: string;
node_exec_id: string;
@@ -280,6 +282,24 @@ export type NodeExecutionResult = {
end_time?: Date;
};
/* Mirror of backend/server/v2/library/model.py:LibraryAgent */
export type LibraryAgent = {
id: LibraryAgentID;
agent_id: GraphID;
agent_version: number;
preset_id: string | null;
updated_at: Date;
name: string;
description: string;
input_schema: BlockIOObjectSubSchema;
output_schema: BlockIOObjectSubSchema;
is_favorite: boolean;
is_created_by_user: boolean;
is_latest_version: boolean;
};
export type LibraryAgentID = Brand<string, "LibraryAgentID">;
/* Mirror of backend/server/integrations/router.py:CredentialsMetaResponse */
export type CredentialsMetaResponse = {
id: string;
@@ -348,14 +368,14 @@ export type UserPasswordCredentials = BaseCredentials & {
// Mirror of backend/backend/data/notifications.py:NotificationType
export type NotificationType =
| "agent_run"
| "zero_balance"
| "low_balance"
| "block_execution_failed"
| "continuous_agent_error"
| "daily_summary"
| "weekly_summary"
| "monthly_summary";
| "AGENT_RUN"
| "ZERO_BALANCE"
| "LOW_BALANCE"
| "BLOCK_EXECUTION_FAILED"
| "CONTINUOUS_AGENT_ERROR"
| "DAILY_SUMMARY"
| "WEEKLY_SUMMARY"
| "MONTHLY_SUMMARY";
// Mirror of backend/backend/data/notifications.py:NotificationPreference
export type NotificationPreferenceDTO = {
@@ -527,7 +547,7 @@ export type Schedule = {
name: string;
cron: string;
user_id: string;
graph_id: string;
graph_id: GraphID;
graph_version: number;
input_data: { [key: string]: any };
next_run_time: string;
@@ -535,7 +555,7 @@ export type Schedule = {
export type ScheduleCreatable = {
cron: string;
graph_id: string;
graph_id: GraphID;
graph_version: number;
input_data: { [key: string]: any };
};
@@ -598,12 +618,13 @@ export interface CreateAPIKeyResponse {
}
export interface CreditTransaction {
transaction_key: string;
transaction_time: Date;
transaction_type: string;
amount: number;
balance: number;
description: string;
usage_graph_id: string;
usage_graph_id: GraphID;
usage_execution_id: string;
usage_node_count: number;
usage_starting_time: Date;
@@ -613,3 +634,22 @@ export interface TransactionHistory {
transactions: CreditTransaction[];
next_transaction_time: Date | null;
}
export interface RefundRequest {
id: string;
user_id: string;
transaction_key: string;
amount: number;
reason: string;
result: string | null;
status: string;
created_at: Date;
updated_at: Date;
}
/* *** UTILITIES *** */
/** Use branded types for IDs -> deny mixing IDs between different object classes */
export type Brand<T, Brand extends string> = T & {
readonly [B in Brand as `__${B}_brand`]: never;
};