mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge branch 'andrewhooker2/secrt-1077-add-email-service-settings-page' into ntindle/secrt-1077-add-email-service
This commit is contained in:
@@ -217,6 +217,14 @@ class NotificationTypeOverride:
|
||||
}[self.notification_type]
|
||||
|
||||
|
||||
class NotificationPreferenceDTO(BaseModel):
|
||||
email: EmailStr = Field(..., description="User's email address")
|
||||
preferences: dict[NotificationType, bool] = Field(
|
||||
..., description="Which notifications the user wants"
|
||||
)
|
||||
daily_limit: int = Field(..., description="Max emails per day")
|
||||
|
||||
|
||||
class NotificationPreference(BaseModel):
|
||||
user_id: str
|
||||
email: EmailStr
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
@@ -263,7 +262,6 @@ class AsyncRabbitMQ(RabbitMQBase):
|
||||
self,
|
||||
routing_key: str,
|
||||
message: str,
|
||||
expiration: Optional[timedelta] = None,
|
||||
exchange: Optional[Exchange] = None,
|
||||
persistent: bool = True,
|
||||
) -> None:
|
||||
@@ -286,7 +284,6 @@ class AsyncRabbitMQ(RabbitMQBase):
|
||||
if persistent
|
||||
else aio_pika.DeliveryMode.NOT_PERSISTENT
|
||||
),
|
||||
expiration=expiration if expiration else None,
|
||||
),
|
||||
routing_key=routing_key,
|
||||
)
|
||||
|
||||
@@ -7,10 +7,11 @@ from fastapi import HTTPException
|
||||
from prisma import Json
|
||||
from prisma.enums import NotificationType
|
||||
from prisma.models import User
|
||||
from prisma.types import UserUpdateInput
|
||||
|
||||
from backend.data.db import prisma
|
||||
from backend.data.model import UserIntegrations, UserMetadata, UserMetadataRaw
|
||||
from backend.data.notifications import NotificationPreference
|
||||
from backend.data.notifications import NotificationPreference, NotificationPreferenceDTO
|
||||
from backend.server.v2.store.exceptions import DatabaseError
|
||||
from backend.util.encryption import JSONCryptor
|
||||
|
||||
@@ -57,6 +58,15 @@ async def get_user_email_by_id(user_id: str) -> str:
|
||||
raise DatabaseError(f"Failed to get user email for user {user_id}: {e}") from e
|
||||
|
||||
|
||||
async def update_user_email(user_id: str, email: str):
|
||||
try:
|
||||
await prisma.user.update(where={"id": user_id}, data={"email": email})
|
||||
except Exception as e:
|
||||
raise DatabaseError(
|
||||
f"Failed to update user email for user {user_id}: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
async def create_default_user() -> Optional[User]:
|
||||
user = await prisma.user.find_unique(where={"id": DEFAULT_USER_ID})
|
||||
if not user:
|
||||
@@ -213,3 +223,64 @@ async def get_user_notification_preference(user_id: str) -> NotificationPreferen
|
||||
raise DatabaseError(
|
||||
f"Failed to upsert user notification preference for user {user_id}: {e}"
|
||||
) 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 data.preferences.get(NotificationType.AGENT_RUN):
|
||||
update_data["notifyOnAgentRun"] = True
|
||||
if data.preferences.get(NotificationType.ZERO_BALANCE):
|
||||
update_data["notifyOnZeroBalance"] = True
|
||||
if data.preferences.get(NotificationType.LOW_BALANCE):
|
||||
update_data["notifyOnLowBalance"] = True
|
||||
if data.preferences.get(NotificationType.BLOCK_EXECUTION_FAILED):
|
||||
update_data["notifyOnBlockExecutionFailed"] = True
|
||||
if data.preferences.get(NotificationType.CONTINUOUS_AGENT_ERROR):
|
||||
update_data["notifyOnContinuousAgentError"] = True
|
||||
if data.preferences.get(NotificationType.DAILY_SUMMARY):
|
||||
update_data["notifyOnDailySummary"] = True
|
||||
if data.preferences.get(NotificationType.WEEKLY_SUMMARY):
|
||||
update_data["notifyOnWeeklySummary"] = True
|
||||
if data.preferences.get(NotificationType.MONTHLY_SUMMARY):
|
||||
update_data["notifyOnMonthlySummary"] = True
|
||||
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
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from aio_pika.exceptions import QueueEmpty
|
||||
from autogpt_libs.utils.cache import thread_cached
|
||||
from prisma.models import UserNotificationBatch
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -19,13 +10,11 @@ from backend.data.notifications import (
|
||||
NotificationEventDTO,
|
||||
NotificationEventModel,
|
||||
NotificationResult,
|
||||
get_batch_delay,
|
||||
get_data_type,
|
||||
)
|
||||
from backend.data.rabbitmq import Exchange, ExchangeType, Queue, RabbitMQConfig
|
||||
from backend.executor.database import DatabaseManager
|
||||
from backend.notifications.email import EmailSender
|
||||
from backend.notifications.summary import SummaryManager
|
||||
from backend.util.service import AppService, expose, get_service_client
|
||||
from backend.util.settings import Settings
|
||||
|
||||
@@ -40,8 +29,6 @@ def create_notification_config() -> RabbitMQConfig:
|
||||
"""Create RabbitMQ configuration for notifications"""
|
||||
notification_exchange = Exchange(name="notifications", type=ExchangeType.TOPIC)
|
||||
|
||||
# batch_exchange = Exchange(name="batching", type=ExchangeType.TOPIC)
|
||||
|
||||
summary_exchange = Exchange(name="summaries", type=ExchangeType.TOPIC)
|
||||
|
||||
dead_letter_exchange = Exchange(name="dead_letter", type=ExchangeType.DIRECT)
|
||||
@@ -67,25 +54,6 @@ def create_notification_config() -> RabbitMQConfig:
|
||||
"x-dead-letter-routing-key": "failed.backoff",
|
||||
},
|
||||
),
|
||||
# Batch queues for aggregation
|
||||
# Queue(
|
||||
# name="hourly_batch", exchange=batch_exchange, routing_key="batch.hourly.#"
|
||||
# ),
|
||||
# Queue(name="daily_batch", exchange=batch_exchange, routing_key="batch.daily.#"),
|
||||
# Queue(
|
||||
# name="batch_rechecks_delay",
|
||||
# exchange=delay_exchange,
|
||||
# routing_key="batch.*.recheck",
|
||||
# arguments={
|
||||
# "x-dead-letter-exchange": batch_exchange.name,
|
||||
# "x-dead-letter-routing-key": "batch.recheck",
|
||||
# },
|
||||
# ),
|
||||
# Queue(
|
||||
# name="batch_rechecks",
|
||||
# exchange=batch_exchange,
|
||||
# routing_key="batch.recheck",
|
||||
# ),
|
||||
# Summary queues
|
||||
Queue(
|
||||
name="daily_summary_trigger",
|
||||
@@ -146,10 +114,6 @@ class NotificationManager(AppService):
|
||||
return f"notification.immediate.{event.type.value}"
|
||||
elif event.strategy == BatchingStrategy.BACKOFF:
|
||||
return f"notification.backoff.{event.type.value}"
|
||||
# elif event.strategy == BatchingStrategy.HOURLY:
|
||||
# return f"batch.hourly.{event.type.value}"
|
||||
# else: # DAILY
|
||||
# return f"batch.daily.{event.type.value}"
|
||||
return f"notification.{event.type.value}"
|
||||
|
||||
@expose
|
||||
@@ -166,14 +130,6 @@ class NotificationManager(AppService):
|
||||
|
||||
logger.info(f"Recieved Request to queue {message=}")
|
||||
|
||||
# Get the appropriate exchange based on strategy
|
||||
exchange = None
|
||||
# if parsed_event.strategy in [
|
||||
# BatchingStrategy.HOURLY,
|
||||
# BatchingStrategy.DAILY,
|
||||
# ]:
|
||||
# exchange = "batching"
|
||||
# else:
|
||||
exchange = "notifications"
|
||||
|
||||
# Publish to RabbitMQ
|
||||
@@ -196,104 +152,6 @@ class NotificationManager(AppService):
|
||||
logger.error(f"Error queueing notification: {e}")
|
||||
return NotificationResult(success=False, message=str(e))
|
||||
|
||||
async def _schedule_next_summary(self, summary_type: str, user_id: str):
|
||||
"""Schedule the next summary generation using RabbitMQ delayed messages"""
|
||||
routing_key = f"summary.{summary_type}"
|
||||
message = json.dumps(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"summary_type": summary_type,
|
||||
"scheduled_at": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
await self.rabbit.publish_message(
|
||||
routing_key=routing_key,
|
||||
message=message,
|
||||
exchange=next(
|
||||
ex for ex in self.rabbit_config.exchanges if ex.name == "summaries"
|
||||
),
|
||||
)
|
||||
|
||||
@expose
|
||||
async def process_summaries(
|
||||
self, summary_type: Optional[str] = None, user_id: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Process summaries for specified type and user, or all if not specified.
|
||||
This is exposed for manual triggering but normally runs on schedule.
|
||||
"""
|
||||
now = datetime.now()
|
||||
db = get_db_client()
|
||||
|
||||
summary_configs = {
|
||||
"daily": (1, "days"),
|
||||
"weekly": (7, "days"),
|
||||
"monthly": (1, "months"),
|
||||
}
|
||||
|
||||
# If summary_type specified, only process that type
|
||||
if summary_type:
|
||||
summary_configs = {
|
||||
k: v for k, v in summary_configs.items() if k == summary_type
|
||||
}
|
||||
|
||||
for summary_type, period_info in summary_configs.items():
|
||||
amount, unit = period_info
|
||||
start_time = self._get_period_start(now, amount, unit)
|
||||
|
||||
# Calculate end time
|
||||
if unit == "months":
|
||||
if start_time.month == 12:
|
||||
end_time = datetime(start_time.year + 1, 1, 1)
|
||||
else:
|
||||
end_time = datetime(start_time.year, start_time.month + 1, 1)
|
||||
else:
|
||||
end_time = start_time + timedelta(**{unit: amount})
|
||||
|
||||
# Get users to process
|
||||
if user_id:
|
||||
# users = [db.get_user(user_id)]
|
||||
users = []
|
||||
else:
|
||||
users = db.get_active_user_ids_in_timerange(
|
||||
start_time.isoformat(), end_time.isoformat()
|
||||
)
|
||||
|
||||
for user_id in users:
|
||||
await self.summary_manager.generate_summary(
|
||||
summary_type, user_id, start_time, end_time, self
|
||||
)
|
||||
# Schedule next summary if this wasn't manually triggered
|
||||
if not user_id and not summary_type:
|
||||
await self._schedule_next_summary(summary_type, user_id)
|
||||
|
||||
async def _process_summary_trigger(self, message: str):
|
||||
"""Process a summary trigger message"""
|
||||
try:
|
||||
|
||||
data = json.loads(message)
|
||||
await self.process_summaries(
|
||||
summary_type=data["summary_type"], user_id=data["user_id"]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing summary trigger: {e}")
|
||||
|
||||
def _get_period_start(self, now: datetime, amount: int, unit: str) -> datetime:
|
||||
"""Get the start time for a summary period"""
|
||||
if unit == "days":
|
||||
if amount == 1: # Daily summary
|
||||
return now.replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
) - timedelta(days=1)
|
||||
else: # Weekly summary
|
||||
return now - timedelta(days=now.weekday() + 7)
|
||||
else: # Monthly summary
|
||||
if now.month == 1:
|
||||
return datetime(now.year - 1, 12, 1)
|
||||
else:
|
||||
return datetime(now.year, now.month - 1, 1)
|
||||
|
||||
async def _process_immediate(self, message: str) -> bool:
|
||||
"""Process a single notification immediately, returning whether to put into the failed queue"""
|
||||
try:
|
||||
@@ -322,75 +180,6 @@ class NotificationManager(AppService):
|
||||
logger.error(f"Error processing notification: {e}")
|
||||
return False
|
||||
|
||||
# def should_send(self, batch: UserNotificationBatch) -> bool:
|
||||
# """Determine if a batch should be sent"""
|
||||
# if not batch.notifications:
|
||||
# return False
|
||||
# # if any notifications are older than the batch delay, send them
|
||||
# if any(
|
||||
# notification.created_at < datetime.now() - get_batch_delay(batch.type)
|
||||
# for notification in batch.notifications
|
||||
# if isinstance(notification, NotificationEventModel)
|
||||
# ):
|
||||
# logger.info(f"Sending batch of {len(batch.notifications)} notifications")
|
||||
# return True
|
||||
# return False
|
||||
|
||||
# async def _process_batch_message(self, message: str) -> bool:
|
||||
# """Process a batch notification & return status from processing"""
|
||||
# try:
|
||||
# logger.info(f"Processing batch message: {message}")
|
||||
# event = NotificationEventDTO.model_validate_json(message)
|
||||
# logger.info(f"Event: {event}")
|
||||
# parsed_event = NotificationEventModel[
|
||||
# get_data_type(event.type)
|
||||
# ].model_validate_json(message)
|
||||
# logger.info(f"Processing batch ingestion of {parsed_event}")
|
||||
# # Implementation of batch notification sending would go here
|
||||
# # Add to database
|
||||
# db = get_db_client()
|
||||
# logger.info(f"Processing batch ingestion of {parsed_event}")
|
||||
# logger.info(f"type of event: {type(parsed_event)}")
|
||||
# batch = db.create_or_add_to_user_notification_batch(
|
||||
# parsed_event.user_id, parsed_event.type, parsed_event.model_dump_json()
|
||||
# )
|
||||
# batch = UserNotificationBatch.model_validate(batch)
|
||||
# if not batch.notifications:
|
||||
# logger.info(
|
||||
# f"No notifications to send for batch of {parsed_event.user_id}"
|
||||
# )
|
||||
# return True
|
||||
# if self.should_send(batch):
|
||||
# logger.info(
|
||||
# f"Processing batch of {len(batch.notifications)} notifications"
|
||||
# )
|
||||
# db.empty_user_notification_batch(parsed_event.user_id, batch.type)
|
||||
# # self.send_email_with_template(event.user_id, event.type, event.data)
|
||||
# else:
|
||||
# logger.info(
|
||||
# f"Holding on to batch for {parsed_event.user_id} type {batch.type.lower()}"
|
||||
# )
|
||||
# logger.info(f"batch: {batch}")
|
||||
# logger.info(f"delay: {get_batch_delay(batch.type)}")
|
||||
# delay = get_batch_delay(batch.type)
|
||||
|
||||
# await self.rabbit.publish_message(
|
||||
# routing_key=f"batch.{batch.type.lower()}.recheck",
|
||||
# message=json.dumps(
|
||||
# {"user_id": parsed_event.user_id, "type": batch.type}
|
||||
# ),
|
||||
# exchange=next(
|
||||
# ex for ex in self.rabbit_config.exchanges if ex.name == "delay"
|
||||
# ),
|
||||
# expiration=delay,
|
||||
# )
|
||||
|
||||
# return True
|
||||
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error processing batch: {e}")
|
||||
# return False
|
||||
|
||||
def run_service(self):
|
||||
logger.info(f"[{self.service_name}] Started notification service")
|
||||
|
||||
@@ -401,27 +190,6 @@ class NotificationManager(AppService):
|
||||
channel.get_queue("immediate_notifications")
|
||||
)
|
||||
|
||||
backoff_queue = self.run_and_wait(channel.get_queue("backoff_notifications"))
|
||||
|
||||
# hourly_queue = self.run_and_wait(channel.get_queue("hourly_batch"))
|
||||
|
||||
# daily_queue = self.run_and_wait(channel.get_queue("daily_batch"))
|
||||
|
||||
# recheck_queue = self.run_and_wait(channel.get_queue("batch_rechecks"))
|
||||
|
||||
# Set up summary queues
|
||||
summary_queues = []
|
||||
for summary_type in ["daily", "weekly", "monthly"]:
|
||||
queue = self.run_and_wait(
|
||||
channel.get_queue(f"{summary_type}_summary_trigger")
|
||||
)
|
||||
summary_queues.append(queue)
|
||||
|
||||
# Initial summary scheduling
|
||||
for user_id in get_db_client().get_active_users_ids():
|
||||
for summary_type in ["daily", "weekly", "monthly"]:
|
||||
self.run_and_wait(self._schedule_next_summary(summary_type, user_id))
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Process immediate notifications
|
||||
@@ -439,87 +207,6 @@ class NotificationManager(AppService):
|
||||
except QueueEmpty:
|
||||
logger.debug("Immediate queue empty")
|
||||
|
||||
# Process backoff notifications similarly
|
||||
try:
|
||||
message = self.run_and_wait(backoff_queue.get())
|
||||
|
||||
if message:
|
||||
# success = self.run_and_wait(
|
||||
# self._process_backoff(message.body.decode())
|
||||
# )
|
||||
success = True
|
||||
if success:
|
||||
self.run_and_wait(message.ack())
|
||||
else:
|
||||
# If failed, will go to DLQ with delay
|
||||
self.run_and_wait(message.reject(requeue=True))
|
||||
except QueueEmpty:
|
||||
logger.debug("Backoff queue empty")
|
||||
|
||||
# # Add to plan db/process batch and delay or send
|
||||
# for queue in [
|
||||
# hourly_queue,
|
||||
# daily_queue,
|
||||
# ]:
|
||||
# try:
|
||||
# message = self.run_and_wait(queue.get(no_ack=False))
|
||||
# if message:
|
||||
# success = self.run_and_wait(
|
||||
# self._process_batch_message(message.body.decode())
|
||||
# )
|
||||
# if success:
|
||||
# self.run_and_wait(message.ack())
|
||||
# else:
|
||||
# self.run_and_wait(message.reject(requeue=True))
|
||||
# except QueueEmpty:
|
||||
# logger.debug(f"Queue empty: {queue}")
|
||||
|
||||
# # Process batch rechecks
|
||||
# try:
|
||||
# message = self.run_and_wait(recheck_queue.get())
|
||||
# if message:
|
||||
# logger.info(f"Processing recheck message: {message}")
|
||||
# data = json.loads(message.body.decode())
|
||||
|
||||
# db = get_db_client()
|
||||
# batch = db.get_user_notification_batch(
|
||||
# data["user_id"], data["type"]
|
||||
# )
|
||||
# if batch and self.should_send(
|
||||
# UserNotificationBatch.model_validate(batch)
|
||||
# ):
|
||||
# # Send and empty the batch
|
||||
# db.empty_user_notification_batch(
|
||||
# data["user_id"], data["type"]
|
||||
# )
|
||||
# if batch.notifications:
|
||||
# self.email_sender.send_templated(
|
||||
# batch.type,
|
||||
# data["user_id"],
|
||||
# [
|
||||
# NotificationEventModel[
|
||||
# get_data_type(notification.type)
|
||||
# ].model_validate(notification)
|
||||
# for notification in batch.notifications
|
||||
# ],
|
||||
# )
|
||||
# self.run_and_wait(message.ack())
|
||||
# except QueueEmpty:
|
||||
# logger.debug("Recheck queue empty")
|
||||
|
||||
# Process summary triggers
|
||||
for queue in summary_queues:
|
||||
try:
|
||||
message = self.run_and_wait(queue.get())
|
||||
|
||||
if message:
|
||||
self.run_and_wait(
|
||||
self._process_summary_trigger(message.body.decode())
|
||||
)
|
||||
self.run_and_wait(message.ack())
|
||||
except QueueEmpty:
|
||||
logger.debug(f"Queue empty: {queue}")
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
except QueueEmpty as e:
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
# backend/notifications/summary.py
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from autogpt_libs.utils.cache import thread_cached
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.executor.database import DatabaseManager
|
||||
|
||||
from backend.data.notifications import (
|
||||
DailySummaryData,
|
||||
MonthlySummaryData,
|
||||
NotificationEventModel,
|
||||
NotificationType,
|
||||
WeeklySummaryData,
|
||||
)
|
||||
from backend.util.service import get_service_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SummaryManager:
|
||||
"""Handles all summary generation and stats collection"""
|
||||
|
||||
def __init__(self):
|
||||
self.summary_keys = {
|
||||
"daily": "summary:daily:",
|
||||
"weekly": "summary:weekly:",
|
||||
"monthly": "summary:monthly:",
|
||||
}
|
||||
self.last_check_keys = {
|
||||
"daily": "summary:last_check:daily",
|
||||
"weekly": "summary:last_check:weekly",
|
||||
"monthly": "summary:last_check:monthly",
|
||||
}
|
||||
|
||||
async def collect_stats(
|
||||
self, user_id: str, start_time: datetime, end_time: datetime
|
||||
) -> dict:
|
||||
"""Collect execution statistics for a time period"""
|
||||
db = get_db_client()
|
||||
executions = db.get_executions_in_timerange(
|
||||
user_id=user_id,
|
||||
start_time=start_time.isoformat(),
|
||||
end_time=end_time.isoformat(),
|
||||
)
|
||||
|
||||
stats = {
|
||||
"total_credits_used": 0,
|
||||
"total_executions": len(executions),
|
||||
"total_execution_time": 0,
|
||||
"successful_runs": 0,
|
||||
"failed_runs": 0,
|
||||
"agent_usage": defaultdict(float),
|
||||
}
|
||||
|
||||
# for execution in executions:
|
||||
# stats["total_credits_used"] += execution.credits_used
|
||||
# stats["total_execution_time"] += execution.execution_time
|
||||
# stats[
|
||||
# "successful_runs" if execution.status == "completed" else "failed_runs"
|
||||
# ] += 1
|
||||
# stats["agent_usage"][execution.agent_type] += execution.credits_used
|
||||
|
||||
most_used = (
|
||||
max(stats["agent_usage"].items(), key=lambda x: x[1])[0]
|
||||
if stats["agent_usage"]
|
||||
else "None"
|
||||
)
|
||||
|
||||
return {
|
||||
"total_credits_used": stats["total_credits_used"],
|
||||
"total_executions": stats["total_executions"],
|
||||
"most_used_agent": most_used,
|
||||
"total_execution_time": stats["total_execution_time"],
|
||||
"successful_runs": stats["successful_runs"],
|
||||
"failed_runs": stats["failed_runs"],
|
||||
"average_execution_time": (
|
||||
stats["total_execution_time"] / stats["total_executions"]
|
||||
if stats["total_executions"]
|
||||
else 0
|
||||
),
|
||||
"cost_breakdown": dict(stats["agent_usage"]),
|
||||
}
|
||||
|
||||
async def should_generate_summary(self, summary_type: str, redis) -> bool:
|
||||
"""Check if we should generate a summary based on last check time"""
|
||||
last_check_key = self.last_check_keys[summary_type]
|
||||
last_check = await redis.get(last_check_key)
|
||||
|
||||
if not last_check:
|
||||
return True
|
||||
|
||||
last_check_time = datetime.fromisoformat(last_check)
|
||||
now = datetime.now()
|
||||
|
||||
if summary_type == "daily":
|
||||
return now.date() != last_check_time.date()
|
||||
elif summary_type == "weekly":
|
||||
return now.isocalendar()[1] != last_check_time.isocalendar()[1]
|
||||
else: # monthly
|
||||
return now.month != last_check_time.month
|
||||
|
||||
async def generate_summary(
|
||||
self,
|
||||
summary_type: str,
|
||||
user_id: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
notification_manager,
|
||||
) -> bool:
|
||||
"""Generate and send a summary for a user"""
|
||||
try:
|
||||
stats = await self.collect_stats(user_id, start_time, end_time)
|
||||
if not stats["total_executions"]:
|
||||
return False
|
||||
|
||||
if summary_type == "daily":
|
||||
data = DailySummaryData(date=start_time, **stats)
|
||||
type_ = NotificationType.DAILY_SUMMARY
|
||||
notification = NotificationEventModel(
|
||||
user_id=user_id,
|
||||
type=type_,
|
||||
data=data,
|
||||
)
|
||||
elif summary_type == "weekly":
|
||||
data = WeeklySummaryData(
|
||||
start_date=start_time,
|
||||
end_date=end_time,
|
||||
week_number=start_time.isocalendar()[1],
|
||||
year=start_time.year,
|
||||
**stats,
|
||||
)
|
||||
type_ = NotificationType.WEEKLY_SUMMARY
|
||||
notification = NotificationEventModel(
|
||||
user_id=user_id,
|
||||
type=type_,
|
||||
data=data,
|
||||
)
|
||||
else:
|
||||
data = MonthlySummaryData(
|
||||
month=start_time.month, year=start_time.year, **stats
|
||||
)
|
||||
type_ = NotificationType.MONTHLY_SUMMARY
|
||||
notification = NotificationEventModel(
|
||||
user_id=user_id,
|
||||
type=type_,
|
||||
data=data,
|
||||
)
|
||||
return await notification_manager._process_immediate(notification)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error generating {summary_type} summary for user {user_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@thread_cached
|
||||
def get_db_client() -> "DatabaseManager":
|
||||
from backend.executor import DatabaseManager
|
||||
|
||||
return get_service_client(DatabaseManager)
|
||||
@@ -40,7 +40,13 @@ from backend.data.credit import (
|
||||
get_user_credit_model,
|
||||
set_auto_top_up,
|
||||
)
|
||||
from backend.data.user import get_or_create_user
|
||||
from backend.data.notifications import NotificationPreference, NotificationPreferenceDTO
|
||||
from backend.data.user import (
|
||||
get_or_create_user,
|
||||
get_user_notification_preference,
|
||||
update_user_email,
|
||||
update_user_notification_preference,
|
||||
)
|
||||
from backend.executor import ExecutionManager, ExecutionScheduler, scheduler
|
||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||
from backend.integrations.webhooks.graph_lifecycle_hooks import (
|
||||
@@ -108,6 +114,41 @@ async def get_or_create_user_route(user_data: dict = Depends(auth_middleware)):
|
||||
return user.model_dump()
|
||||
|
||||
|
||||
@v1_router.post(
|
||||
"/auth/user/email", tags=["auth"], dependencies=[Depends(auth_middleware)]
|
||||
)
|
||||
async def update_user_email_route(
|
||||
user_id: Annotated[str, Depends(get_user_id)], email: str = Body(...)
|
||||
) -> dict[str, str]:
|
||||
await update_user_email(user_id, email)
|
||||
|
||||
return {"email": email}
|
||||
|
||||
|
||||
@v1_router.get(
|
||||
"/auth/user/preferences",
|
||||
tags=["auth"],
|
||||
dependencies=[Depends(auth_middleware)],
|
||||
)
|
||||
async def get_preferences(
|
||||
user_id: Annotated[str, Depends(get_user_id)],
|
||||
) -> NotificationPreference:
|
||||
return await get_user_notification_preference(user_id)
|
||||
|
||||
|
||||
@v1_router.post(
|
||||
"/auth/user/preferences",
|
||||
tags=["auth"],
|
||||
dependencies=[Depends(auth_middleware)],
|
||||
)
|
||||
async def update_preferences(
|
||||
user_id: Annotated[str, Depends(get_user_id)],
|
||||
preferences: NotificationPreferenceDTO = Body(...),
|
||||
) -> NotificationPreference:
|
||||
output = await update_user_notification_preference(user_id, preferences)
|
||||
return output
|
||||
|
||||
|
||||
########################################################
|
||||
##################### Blocks ###########################
|
||||
########################################################
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import getServerSupabase from "@/lib/supabase/getServerSupabase";
|
||||
import BackendApi from "@/lib/autogpt-server-api";
|
||||
import { NotificationPreferenceDTO } from "@/lib/autogpt-server-api/types";
|
||||
|
||||
export async function updateSettings(formData: FormData) {
|
||||
const supabase = getServerSupabase();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
// Handle auth-related updates
|
||||
const password = formData.get("password") as string;
|
||||
const email = formData.get("email") as string;
|
||||
|
||||
if (password) {
|
||||
const { error: passwordError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
});
|
||||
|
||||
if (passwordError) {
|
||||
throw new Error(`${passwordError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (email !== user?.email) {
|
||||
const { error: emailError } = await supabase.auth.updateUser({
|
||||
email,
|
||||
});
|
||||
const api = new BackendApi();
|
||||
await api.updateUserEmail(email);
|
||||
|
||||
if (emailError) {
|
||||
throw new Error(`${emailError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const preferencesError = {};
|
||||
|
||||
if (preferencesError) {
|
||||
throw new SettingsError(
|
||||
`Failed to update preferences: ${preferencesError.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function SettingsLoading() {
|
||||
return (
|
||||
<div className="container max-w-2xl py-10">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="mt-2 h-4 w-96" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
{/* Email and Password fields */}
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-28" />
|
||||
|
||||
{/* Agent Notifications */}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-between rounded-lg p-4"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-11" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,30 @@
|
||||
import * as React from "react";
|
||||
import { SettingsInputForm } from "@/components/agptui/SettingsInputForm";
|
||||
import { Metadata } from "next";
|
||||
import SettingsForm from "@/components/profile/settings/SettingsForm";
|
||||
import getServerUser from "@/lib/supabase/getServerUser";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Page() {
|
||||
return <SettingsInputForm />;
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
description: "Manage your account settings and preferences.",
|
||||
};
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const { user, error } = await getServerUser();
|
||||
|
||||
if (error || !user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container max-w-2xl space-y-6 py-10">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">My account</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your account settings and preferences.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsForm user={user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { SettingsInputForm } from "./SettingsInputForm";
|
||||
|
||||
const meta: Meta<typeof SettingsInputForm> = {
|
||||
title: "AGPT UI/Settings/Settings Input Form",
|
||||
component: SettingsInputForm,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SettingsInputForm>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
email: "johndoe@email.com",
|
||||
desktopNotifications: {
|
||||
first: false,
|
||||
second: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useSupabase from "@/hooks/useSupabase";
|
||||
|
||||
interface SettingsInputFormProps {
|
||||
email?: string;
|
||||
desktopNotifications?: {
|
||||
first: boolean;
|
||||
second: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const SettingsInputForm = ({
|
||||
email = "johndoe@email.com",
|
||||
desktopNotifications = { first: false, second: true },
|
||||
}: SettingsInputFormProps) => {
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("");
|
||||
const [passwordsMatch, setPasswordsMatch] = React.useState(true);
|
||||
const { supabase } = useSupabase();
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
if (password !== confirmPassword) {
|
||||
setPasswordsMatch(false);
|
||||
return;
|
||||
}
|
||||
setPasswordsMatch(true);
|
||||
if (supabase) {
|
||||
try {
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password: password,
|
||||
});
|
||||
if (error) {
|
||||
console.error("Error updating user:", error);
|
||||
} else {
|
||||
console.log("User updated successfully");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating user:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
setPasswordsMatch(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1077px] bg-white px-4 pt-8 dark:bg-neutral-900 sm:px-6 sm:pt-16">
|
||||
<h1 className="mb-8 text-2xl font-semibold text-slate-950 dark:text-slate-200 sm:mb-16 sm:text-3xl">
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
{/* My Account Section */}
|
||||
<section aria-labelledby="account-heading">
|
||||
<h2
|
||||
id="account-heading"
|
||||
className="mb-8 text-lg font-medium text-neutral-500 dark:text-neutral-400 sm:mb-12"
|
||||
>
|
||||
My account
|
||||
</h2>
|
||||
<div className="flex max-w-[800px] flex-col gap-7">
|
||||
{/* Password Input */}
|
||||
<div className="relative">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="password-input"
|
||||
className="text-base font-medium text-slate-950 dark:text-slate-200"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-[50px] w-full rounded-[35px] border border-neutral-200 bg-transparent px-6 py-3 text-base text-slate-950 dark:border-neutral-700 dark:text-white"
|
||||
aria-label="Password field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Input */}
|
||||
<div className="relative">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="confirm-password-input"
|
||||
className="text-base font-medium text-slate-950 dark:text-slate-200"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password-input"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="h-[50px] w-full rounded-[35px] border border-neutral-200 bg-transparent px-6 py-3 text-base text-slate-950 dark:border-neutral-700 dark:text-white"
|
||||
aria-label="Confirm Password field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
className="my-8 border-t border-neutral-200 dark:border-neutral-700 sm:my-12"
|
||||
role="separator"
|
||||
/>
|
||||
|
||||
<div className="mt-8 flex justify-end">
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-[50px] rounded-[35px] bg-neutral-200 px-6 py-3 font-['Geist'] text-base font-medium text-neutral-800 transition-colors hover:bg-neutral-300 dark:bg-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-600"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
className="h-[50px] rounded-[35px] bg-neutral-800 px-6 py-3 font-['Geist'] text-base font-medium text-white transition-colors hover:bg-neutral-900 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
onClick={handleSaveChanges}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,397 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { User } from "@supabase/supabase-js";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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";
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => {
|
||||
// If password is provided, it must be at least 8 characters
|
||||
if (val) return val.length >= 8;
|
||||
return true;
|
||||
}, "String must contain at least 8 character(s)"),
|
||||
confirmPassword: z.string().optional(),
|
||||
notifyOnAgentRun: z.boolean(),
|
||||
notifyOnZeroBalance: z.boolean(),
|
||||
notifyOnLowBalance: z.boolean(),
|
||||
notifyOnBlockExecutionFailed: z.boolean(),
|
||||
notifyOnContinuousAgentError: z.boolean(),
|
||||
notifyOnDailySummary: z.boolean(),
|
||||
notifyOnWeeklySummary: z.boolean(),
|
||||
notifyOnMonthlySummary: z.boolean(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.password || data.confirmPassword) {
|
||||
return data.password === data.confirmPassword;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
},
|
||||
);
|
||||
|
||||
interface SettingsFormProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function SettingsForm({ user }: SettingsFormProps) {
|
||||
const defaultValues = {
|
||||
email: user.email || "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
notifyOnAgentRun: true,
|
||||
notifyOnZeroBalance: true,
|
||||
notifyOnLowBalance: true,
|
||||
notifyOnBlockExecutionFailed: true,
|
||||
notifyOnContinuousAgentError: true,
|
||||
notifyOnDailySummary: true,
|
||||
notifyOnWeeklySummary: true,
|
||||
notifyOnMonthlySummary: true,
|
||||
};
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (key !== "confirmPassword") {
|
||||
formData.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
await updateSettings(formData);
|
||||
|
||||
toast({
|
||||
title: "Successfully updated settings",
|
||||
});
|
||||
|
||||
form.reset(defaultValues);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Something went wrong",
|
||||
variant: "destructive",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
form.reset(defaultValues);
|
||||
}
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-8"
|
||||
>
|
||||
{/* Account Settings Section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="email" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="************"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="************"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Notifications Section */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<h3 className="text-lg font-medium">Notifications</h3>
|
||||
|
||||
{/* Agent Notifications */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
Agent Notifications
|
||||
</h4>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notifyOnAgentRun"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Agent Run Notifications
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Receive notifications when an agent starts or completes a
|
||||
run
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notifyOnBlockExecutionFailed"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Block Execution Failures
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Get notified when a block execution fails during agent
|
||||
runs
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notifyOnContinuousAgentError"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Continuous Agent Errors
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Receive alerts when an agent encounters repeated errors
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Balance Notifications */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
Balance Notifications
|
||||
</h4>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notifyOnZeroBalance"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Zero Balance Alert
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Get notified when your account balance reaches zero
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notifyOnLowBalance"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Low Balance Warning
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Receive warnings when your balance is running low
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary Reports */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
Summary Reports
|
||||
</h4>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notifyOnDailySummary"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Daily Summary</FormLabel>
|
||||
<FormDescription>
|
||||
Receive a daily summary of your account activity
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notifyOnWeeklySummary"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Weekly Summary</FormLabel>
|
||||
<FormDescription>
|
||||
Get a weekly overview of your account performance
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notifyOnMonthlySummary"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Monthly Summary</FormLabel>
|
||||
<FormDescription>
|
||||
Receive a comprehensive monthly report of your account
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.formState.isSubmitting || !form.formState.isDirty}
|
||||
>
|
||||
{form.formState.isSubmitting ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
15
autogpt_platform/frontend/src/components/ui/skeleton.tsx
Normal file
15
autogpt_platform/frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -32,7 +32,9 @@ import {
|
||||
StoreReview,
|
||||
TransactionHistory,
|
||||
User,
|
||||
NotificationPreferenceDTO,
|
||||
UserPasswordCredentials,
|
||||
NotificationPreference,
|
||||
} from "./types";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import getServerSupabase from "../supabase/getServerSupabase";
|
||||
@@ -81,6 +83,10 @@ export default class BackendAPI {
|
||||
return this._request("POST", "/auth/user", {});
|
||||
}
|
||||
|
||||
updateUserEmail(email: string): Promise<{ email: string }> {
|
||||
return this._request("POST", "/auth/user/email", { email });
|
||||
}
|
||||
|
||||
getUserCredit(page?: string): Promise<{ credits: number }> {
|
||||
try {
|
||||
return this._get(`/credits`, undefined, page);
|
||||
@@ -89,6 +95,16 @@ export default class BackendAPI {
|
||||
}
|
||||
}
|
||||
|
||||
getUserPreferences(): Promise<NotificationPreference> {
|
||||
return this._get("/auth/user/preferences");
|
||||
}
|
||||
|
||||
updateUserPreferences(
|
||||
preferences: NotificationPreferenceDTO,
|
||||
): Promise<NotificationPreference> {
|
||||
return this._request("POST", "/auth/user/preferences", preferences);
|
||||
}
|
||||
|
||||
getAutoTopUpConfig(): Promise<{ amount: number; threshold: number }> {
|
||||
return this._get("/credits/auto-top-up");
|
||||
}
|
||||
|
||||
@@ -346,6 +346,30 @@ export type UserPasswordCredentials = BaseCredentials & {
|
||||
password: string;
|
||||
};
|
||||
|
||||
// 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";
|
||||
|
||||
// Mirror of backend/backend/data/notifications.py:NotificationPreference
|
||||
export type NotificationPreferenceDTO = {
|
||||
email: string;
|
||||
preferences: { [key in NotificationType]: boolean };
|
||||
daily_limit: number;
|
||||
};
|
||||
|
||||
export type NotificationPreference = NotificationPreferenceDTO & {
|
||||
user_id: string;
|
||||
emails_sent_today: number;
|
||||
last_reset_date: Date;
|
||||
};
|
||||
|
||||
/* Mirror of backend/data/integrations.py:Webhook */
|
||||
export type Webhook = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user