Merge branch 'main' into fix_browser-use

This commit is contained in:
Dat LQ. 2025-04-23 20:54:55 +01:00 committed by GitHub
commit 059270ce6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 270 additions and 253 deletions

View File

@ -256,23 +256,17 @@ async def _cleanup_agent_run(agent_run_id: str):
logger.warning(f"Failed to clean up Redis keys for agent run {agent_run_id}: {str(e)}") logger.warning(f"Failed to clean up Redis keys for agent run {agent_run_id}: {str(e)}")
# Non-fatal error, can continue # Non-fatal error, can continue
async def get_or_create_project_sandbox(client, project_id: str, sandbox_cache={}): async def get_or_create_project_sandbox(client, project_id: str):
""" """
Safely get or create a sandbox for a project using distributed locking to avoid race conditions. Get or create a sandbox for a project without distributed locking.
Args: Args:
client: The Supabase client client: The Supabase client
project_id: The project ID to get or create a sandbox for project_id: The project ID to get or create a sandbox for
sandbox_cache: Optional in-memory cache to avoid repeated lookups in the same process
Returns: Returns:
Tuple of (sandbox object, sandbox_id, sandbox_pass) Tuple of (sandbox object, sandbox_id, sandbox_pass)
""" """
# Check in-memory cache first (optimization for repeated calls within same process)
if project_id in sandbox_cache:
logger.debug(f"Using cached sandbox for project {project_id}")
return sandbox_cache[project_id]
# First get the current project data to check if a sandbox already exists # First get the current project data to check if a sandbox already exists
project = await client.table('projects').select('*').eq('project_id', project_id).execute() project = await client.table('projects').select('*').eq('project_id', project_id).execute()
if not project.data or len(project.data) == 0: if not project.data or len(project.data) == 0:
@ -288,134 +282,56 @@ async def get_or_create_project_sandbox(client, project_id: str, sandbox_cache={
try: try:
sandbox = await get_or_start_sandbox(sandbox_id) sandbox = await get_or_start_sandbox(sandbox_id)
# Cache the result
sandbox_cache[project_id] = (sandbox, sandbox_id, sandbox_pass)
return (sandbox, sandbox_id, sandbox_pass) return (sandbox, sandbox_id, sandbox_pass)
except Exception as e: except Exception as e:
logger.error(f"Failed to retrieve existing sandbox {sandbox_id} for project {project_id}: {str(e)}") logger.error(f"Failed to retrieve existing sandbox {sandbox_id} for project {project_id}: {str(e)}")
# Fall through to create a new sandbox if retrieval fails # Fall through to create a new sandbox if retrieval fails
# Need to create a new sandbox - use Redis for distributed locking # Create a new sandbox
lock_key = f"project_sandbox_lock:{project_id}"
lock_value = str(uuid.uuid4()) # Unique identifier for this lock acquisition
lock_timeout = 60 # seconds
# Try to acquire a lock
try: try:
# Attempt to get a lock with a timeout - this is atomic in Redis logger.info(f"Creating new sandbox for project {project_id}")
acquired = await redis.set( sandbox_pass = str(uuid.uuid4())
lock_key, sandbox = create_sandbox(sandbox_pass)
lock_value, sandbox_id = sandbox.id
nx=True, # Only set if key doesn't exist (NX = not exists)
ex=lock_timeout # Auto-expire the lock
)
if not acquired: logger.info(f"Created new sandbox {sandbox_id} with preview: {sandbox.get_preview_link(6080)}/vnc_lite.html?password={sandbox_pass}")
# Someone else is creating a sandbox for this project
logger.info(f"Waiting for another process to create sandbox for project {project_id}")
# Wait and retry a few times # Get preview links
max_retries = 5 vnc_link = sandbox.get_preview_link(6080)
retry_delay = 2 # seconds website_link = sandbox.get_preview_link(8080)
for retry in range(max_retries): # Extract the actual URLs and token from the preview link objects
await asyncio.sleep(retry_delay) vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link).split("url='")[1].split("'")[0]
website_url = website_link.url if hasattr(website_link, 'url') else str(website_link).split("url='")[1].split("'")[0]
# Check if the other process completed # Extract token if available
fresh_project = await client.table('projects').select('*').eq('project_id', project_id).execute() token = None
if fresh_project.data and fresh_project.data[0].get('sandbox', {}).get('id'): if hasattr(vnc_link, 'token'):
sandbox_id = fresh_project.data[0]['sandbox']['id'] token = vnc_link.token
sandbox_pass = fresh_project.data[0]['sandbox']['pass'] elif "token='" in str(vnc_link):
logger.info(f"Another process created sandbox {sandbox_id} for project {project_id}") token = str(vnc_link).split("token='")[1].split("'")[0]
sandbox = await get_or_start_sandbox(sandbox_id) # Update the project with the new sandbox info
# Cache the result update_result = await client.table('projects').update({
sandbox_cache[project_id] = (sandbox, sandbox_id, sandbox_pass) 'sandbox': {
return (sandbox, sandbox_id, sandbox_pass) 'id': sandbox_id,
'pass': sandbox_pass,
'vnc_preview': vnc_url,
'sandbox_url': website_url,
'token': token
}
}).eq('project_id', project_id).execute()
# If we got here, the other process didn't complete in time if not update_result.data:
# Force-acquire the lock by deleting and recreating it logger.error(f"Failed to update project {project_id} with new sandbox {sandbox_id}")
logger.warning(f"Timeout waiting for sandbox creation for project {project_id}, acquiring lock forcefully") raise Exception("Database update failed")
await redis.delete(lock_key)
await redis.set(lock_key, lock_value, ex=lock_timeout)
# We have the lock now - check one more time to avoid race conditions return (sandbox, sandbox_id, sandbox_pass)
fresh_project = await client.table('projects').select('*').eq('project_id', project_id).execute()
if fresh_project.data and fresh_project.data[0].get('sandbox', {}).get('id'):
sandbox_id = fresh_project.data[0]['sandbox']['id']
sandbox_pass = fresh_project.data[0]['sandbox']['pass']
logger.info(f"Sandbox {sandbox_id} was created by another process while acquiring lock for project {project_id}")
# Release the lock
await redis.delete(lock_key)
sandbox = await get_or_start_sandbox(sandbox_id)
# Cache the result
sandbox_cache[project_id] = (sandbox, sandbox_id, sandbox_pass)
return (sandbox, sandbox_id, sandbox_pass)
# Create a new sandbox
try:
logger.info(f"Creating new sandbox for project {project_id}")
sandbox_pass = str(uuid.uuid4())
sandbox = create_sandbox(sandbox_pass)
sandbox_id = sandbox.id
logger.info(f"Created new sandbox {sandbox_id} with preview: {sandbox.get_preview_link(6080)}/vnc_lite.html?password={sandbox_pass}")
# Get preview links
vnc_link = sandbox.get_preview_link(6080)
website_link = sandbox.get_preview_link(8080)
# Extract the actual URLs and token from the preview link objects
vnc_url = vnc_link.url if hasattr(vnc_link, 'url') else str(vnc_link).split("url='")[1].split("'")[0]
website_url = website_link.url if hasattr(website_link, 'url') else str(website_link).split("url='")[1].split("'")[0]
# Extract token if available
token = None
if hasattr(vnc_link, 'token'):
token = vnc_link.token
elif "token='" in str(vnc_link):
token = str(vnc_link).split("token='")[1].split("'")[0]
# Update the project with the new sandbox info
update_result = await client.table('projects').update({
'sandbox': {
'id': sandbox_id,
'pass': sandbox_pass,
'vnc_preview': vnc_url,
'sandbox_url': website_url,
'token': token
}
}).eq('project_id', project_id).execute()
if not update_result.data:
logger.error(f"Failed to update project {project_id} with new sandbox {sandbox_id}")
raise Exception("Database update failed")
# Cache the result
sandbox_cache[project_id] = (sandbox, sandbox_id, sandbox_pass)
return (sandbox, sandbox_id, sandbox_pass)
except Exception as e:
logger.error(f"Error creating sandbox for project {project_id}: {str(e)}")
raise e
except Exception as e: except Exception as e:
logger.error(f"Error in sandbox creation process for project {project_id}: {str(e)}") logger.error(f"Error creating sandbox for project {project_id}: {str(e)}")
raise e raise e
finally:
# Always try to release the lock if we have it
try:
# Only delete the lock if it's still ours
current_value = await redis.get(lock_key)
if current_value == lock_value:
await redis.delete(lock_key)
logger.debug(f"Released lock for project {project_id} sandbox creation")
except Exception as lock_error:
logger.warning(f"Error releasing sandbox creation lock for project {project_id}: {str(lock_error)}")
@router.post("/thread/{thread_id}/agent/start") @router.post("/thread/{thread_id}/agent/start")
async def start_agent( async def start_agent(
thread_id: str, thread_id: str,
@ -894,7 +810,6 @@ async def run_agent_background(
logger.info(f"Agent run background task fully completed for: {agent_run_id} (instance: {instance_id})") logger.info(f"Agent run background task fully completed for: {agent_run_id} (instance: {instance_id})")
# New background task function
async def generate_and_update_project_name(project_id: str, prompt: str): async def generate_and_update_project_name(project_id: str, prompt: str):
"""Generates a project name using an LLM and updates the database.""" """Generates a project name using an LLM and updates the database."""
logger.info(f"Starting background task to generate name for project: {project_id}") logger.info(f"Starting background task to generate name for project: {project_id}")
@ -938,6 +853,8 @@ async def generate_and_update_project_name(project_id: str, prompt: str):
else: else:
logger.warning(f"Failed to get valid response from LLM for project {project_id} naming. Response: {response}") logger.warning(f"Failed to get valid response from LLM for project {project_id} naming. Response: {response}")
# Update database if name was generated
if generated_name: if generated_name:
update_result = await client.table('projects') \ update_result = await client.table('projects') \
.update({"name": generated_name}) \ .update({"name": generated_name}) \
@ -1024,7 +941,7 @@ async def initiate_agent_with_files(
) )
# ----------------------------------------- # -----------------------------------------
# 3. Create Sandbox - Using safe method with distributed locking # 3. Create Sandbox
try: try:
sandbox, sandbox_id, sandbox_pass = await get_or_create_project_sandbox(client, project_id) sandbox, sandbox_id, sandbox_pass = await get_or_create_project_sandbox(client, project_id)
logger.info(f"Using sandbox {sandbox_id} for new project {project_id}") logger.info(f"Using sandbox {sandbox_id} for new project {project_id}")

View File

@ -247,9 +247,9 @@ class WebSearchTool(Tool):
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
print(f"--- Raw Tavily Response ---") # print(f"--- Raw Tavily Response ---")
print(data) # print(data)
print(f"--------------------------") # print(f"--------------------------")
# Normalise Tavily extract output to a list of dicts # Normalise Tavily extract output to a list of dicts
extracted = [] extracted = []

View File

@ -46,9 +46,9 @@ async def lifespan(app: FastAPI):
# Initialize the sandbox API with shared resources # Initialize the sandbox API with shared resources
sandbox_api.initialize(db) sandbox_api.initialize(db)
# Initialize Redis before restoring agent runs # Redis is no longer needed for a single-server setup
from services import redis # from services import redis
await redis.initialize_async() # await redis.initialize_async()
asyncio.create_task(agent_api.restore_running_agent_runs()) asyncio.create_task(agent_api.restore_running_agent_runs())

View File

@ -74,7 +74,6 @@ async def verify_sandbox_access(client, sandbox_id: str, user_id: Optional[str]
async def get_sandbox_by_id_safely(client, sandbox_id: str): async def get_sandbox_by_id_safely(client, sandbox_id: str):
""" """
Safely retrieve a sandbox object by its ID, using the project that owns it. Safely retrieve a sandbox object by its ID, using the project that owns it.
This prevents race conditions by leveraging the distributed locking mechanism.
Args: Args:
client: The Supabase client client: The Supabase client
@ -97,7 +96,7 @@ async def get_sandbox_by_id_safely(client, sandbox_id: str):
logger.debug(f"Found project {project_id} for sandbox {sandbox_id}") logger.debug(f"Found project {project_id} for sandbox {sandbox_id}")
try: try:
# Use the race-condition-safe function to get the sandbox # Get the sandbox
sandbox, retrieved_sandbox_id, sandbox_pass = await get_or_create_project_sandbox(client, project_id) sandbox, retrieved_sandbox_id, sandbox_pass = await get_or_create_project_sandbox(client, project_id)
# Verify we got the right sandbox # Verify we got the right sandbox
@ -259,7 +258,6 @@ async def ensure_project_sandbox_active(
""" """
Ensure that a project's sandbox is active and running. Ensure that a project's sandbox is active and running.
Checks the sandbox status and starts it if it's not running. Checks the sandbox status and starts it if it's not running.
Uses distributed locking to prevent race conditions.
""" """
client = await db.client client = await db.client
@ -286,8 +284,8 @@ async def ensure_project_sandbox_active(
raise HTTPException(status_code=403, detail="Not authorized to access this project") raise HTTPException(status_code=403, detail="Not authorized to access this project")
try: try:
# Use the safer function that handles race conditions with distributed locking # Get or create the sandbox
logger.info(f"Ensuring sandbox is active for project {project_id} using distributed locking") logger.info(f"Ensuring sandbox is active for project {project_id}")
sandbox, sandbox_id, sandbox_pass = await get_or_create_project_sandbox(client, project_id) sandbox, sandbox_id, sandbox_pass = await get_or_create_project_sandbox(client, project_id)
logger.info(f"Successfully ensured sandbox {sandbox_id} is active for project {project_id}") logger.info(f"Successfully ensured sandbox {sandbox_id} is active for project {project_id}")

View File

@ -80,7 +80,7 @@ def initialize():
socket_connect_timeout=5.0, # Connection timeout socket_connect_timeout=5.0, # Connection timeout
retry_on_timeout=True, # Auto-retry on timeout retry_on_timeout=True, # Auto-retry on timeout
health_check_interval=30, # Check connection health every 30 seconds health_check_interval=30, # Check connection health every 30 seconds
max_connections=100 # Limit connections to prevent overloading max_connections=10 # Limit connections to prevent overloading
) )
return client return client
@ -141,29 +141,10 @@ async def get_client():
# Centralized Redis operation functions with built-in retry logic # Centralized Redis operation functions with built-in retry logic
async def set(key, value, ex=None, nx=None, xx=None): async def set(key, value, ex=None):
""" """Set a Redis key with automatic retry."""
Set a Redis key with automatic retry.
Args:
key: The key to set
value: The value to set
ex: Expiration time in seconds
nx: Only set the key if it does not already exist
xx: Only set the key if it already exists
"""
redis_client = await get_client() redis_client = await get_client()
return await with_retry(redis_client.set, key, value, ex=ex)
# Build the kwargs based on which parameters are provided
kwargs = {}
if ex is not None:
kwargs['ex'] = ex
if nx is not None:
kwargs['nx'] = nx
if xx is not None:
kwargs['xx'] = xx
return await with_retry(redis_client.set, key, value, **kwargs)
async def get(key, default=None): async def get(key, default=None):
"""Get a Redis key with automatic retry.""" """Get a Redis key with automatic retry."""

View File

@ -3,7 +3,7 @@ from typing import Dict, Optional, Tuple
# Define subscription tiers and their monthly limits (in minutes) # Define subscription tiers and their monthly limits (in minutes)
SUBSCRIPTION_TIERS = { SUBSCRIPTION_TIERS = {
'price_1RGJ9GG6l1KZGqIroxSqgphC': {'name': 'free', 'minutes': 1000000}, 'price_1RGJ9GG6l1KZGqIroxSqgphC': {'name': 'free', 'minutes': 100000},
'price_1RGJ9LG6l1KZGqIrd9pwzeNW': {'name': 'base', 'minutes': 300}, # 100 hours = 6000 minutes 'price_1RGJ9LG6l1KZGqIrd9pwzeNW': {'name': 'base', 'minutes': 300}, # 100 hours = 6000 minutes
'price_1RGJ9JG6l1KZGqIrVUU4ZRv6': {'name': 'extra', 'minutes': 2400} # 100 hours = 6000 minutes 'price_1RGJ9JG6l1KZGqIrVUU4ZRv6': {'name': 'extra', 'minutes': 2400} # 100 hours = 6000 minutes
} }
@ -76,23 +76,26 @@ async def check_billing_status(client, account_id: str) -> Tuple[bool, str, Opti
subscription = await get_account_subscription(client, account_id) subscription = await get_account_subscription(client, account_id)
# If no subscription, they can use free tier # If no subscription, they can use free tier
if not subscription: # if not subscription:
subscription = { # subscription = {
'price_id': 'price_1RGJ9GG6l1KZGqIroxSqgphC', # Free tier # 'price_id': 'price_1RGJ9GG6l1KZGqIroxSqgphC', # Free tier
'plan_name': 'Free' # 'plan_name': 'Free'
} # }
# Get tier info # if not subscription or subscription.get('price_id') is None or subscription.get('price_id') == 'price_1RGJ9GG6l1KZGqIroxSqgphC':
tier_info = SUBSCRIPTION_TIERS.get(subscription['price_id']) # return False, "You are not subscribed to any plan. Please upgrade your plan to continue.", subscription
if not tier_info:
return False, "Invalid subscription tier", subscription
# Calculate current month's usage # # Get tier info
current_usage = await calculate_monthly_usage(client, account_id) # tier_info = SUBSCRIPTION_TIERS.get(subscription['price_id'])
# if not tier_info:
# return False, "Invalid subscription tier", subscription
# Check if within limits # # Calculate current month's usage
if current_usage >= tier_info['minutes']: # current_usage = await calculate_monthly_usage(client, account_id)
return False, f"Monthly limit of {tier_info['minutes']} minutes reached. Please upgrade your plan or wait until next month.", subscription
# # Check if within limits
# if current_usage >= tier_info['minutes']:
# return False, f"Monthly limit of {tier_info['minutes']} minutes reached. Please upgrade your plan or wait until next month.", subscription
return True, "OK", subscription return True, "OK", subscription

View File

@ -243,6 +243,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
const messagesLoadedRef = useRef(false); const messagesLoadedRef = useRef(false);
const agentRunsCheckedRef = useRef(false); const agentRunsCheckedRef = useRef(false);
const previousAgentStatus = useRef<typeof agentStatus>('idle'); const previousAgentStatus = useRef<typeof agentStatus>('idle');
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null); // POLLING FOR MESSAGES
const handleProjectRenamed = useCallback((newName: string) => { const handleProjectRenamed = useCallback((newName: string) => {
setProjectName(newName); setProjectName(newName);
@ -968,6 +969,64 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
} }
}, [projectName]); }, [projectName]);
// POLLING FOR MESSAGES
// Set up polling for messages
useEffect(() => {
// Function to fetch messages
const fetchMessages = async () => {
if (!threadId) return;
try {
console.log('[POLLING] Refetching messages...');
const messagesData = await getMessages(threadId);
if (messagesData) {
console.log(`[POLLING] Refetch completed with ${messagesData.length} messages`);
// Map API message type to UnifiedMessage type
const unifiedMessages = (messagesData || [])
.filter(msg => msg.type !== 'status')
.map((msg: ApiMessageType) => ({
message_id: msg.message_id || null,
thread_id: msg.thread_id || threadId,
type: (msg.type || 'system') as UnifiedMessage['type'],
is_llm_message: Boolean(msg.is_llm_message),
content: msg.content || '',
metadata: msg.metadata || '{}',
created_at: msg.created_at || new Date().toISOString(),
updated_at: msg.updated_at || new Date().toISOString()
}));
setMessages(unifiedMessages);
// Only auto-scroll if not manually scrolled up
if (!userHasScrolled) {
scrollToBottom('smooth');
}
}
} catch (error) {
console.error('[POLLING] Error fetching messages:', error);
}
};
// Start polling once initial load is complete
if (initialLoadCompleted.current && !pollingIntervalRef.current) {
// Initial fetch
fetchMessages();
// Set up interval (every 2 seconds)
pollingIntervalRef.current = setInterval(fetchMessages, 2000);
}
// Clean up interval when component unmounts
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [threadId, userHasScrolled, initialLoadCompleted]);
// POLLING FOR MESSAGES
// Add another useEffect to ensure messages are refreshed when agent status changes to idle // Add another useEffect to ensure messages are refreshed when agent status changes to idle
useEffect(() => { useEffect(() => {
if (agentStatus === 'idle' && streamHookStatus !== 'streaming' && streamHookStatus !== 'connecting') { if (agentStatus === 'idle' && streamHookStatus !== 'streaming' && streamHookStatus !== 'connecting') {

View File

@ -7,6 +7,7 @@ import {
SidebarProvider, SidebarProvider,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { MaintenanceAlert } from "@/components/maintenance-alert" import { MaintenanceAlert } from "@/components/maintenance-alert"
import { useAccounts } from "@/hooks/use-accounts"
interface DashboardLayoutProps { interface DashboardLayoutProps {
children: React.ReactNode children: React.ReactNode
@ -16,6 +17,8 @@ export default function DashboardLayout({
children, children,
}: DashboardLayoutProps) { }: DashboardLayoutProps) {
const [showMaintenanceAlert, setShowMaintenanceAlert] = useState(false) const [showMaintenanceAlert, setShowMaintenanceAlert] = useState(false)
const { data: accounts } = useAccounts()
const personalAccount = accounts?.find(account => account.personal_account)
useEffect(() => { useEffect(() => {
// Show the maintenance alert when component mounts // Show the maintenance alert when component mounts
@ -35,6 +38,7 @@ export default function DashboardLayout({
open={showMaintenanceAlert} open={showMaintenanceAlert}
onOpenChange={setShowMaintenanceAlert} onOpenChange={setShowMaintenanceAlert}
closeable={true} closeable={true}
accountId={personalAccount?.account_id}
/> />
</SidebarProvider> </SidebarProvider>
) )

View File

@ -1,94 +1,149 @@
"use client" "use client"
import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
import { Clock, Github, X } from "lucide-react" import { AlertCircle, X, Zap, Github } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { AnimatePresence, motion } from "motion/react" import { AnimatePresence, motion } from "motion/react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Portal } from "@/components/ui/portal"
import { cn } from "@/lib/utils"
import { setupNewSubscription } from "@/lib/actions/billing"
import { SubmitButton } from "@/components/ui/submit-button"
import { siteConfig } from "@/lib/home"
interface MaintenanceAlertProps { interface MaintenanceAlertProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
closeable?: boolean closeable?: boolean
accountId?: string | null | undefined
} }
export function MaintenanceAlert({ open, onOpenChange, closeable = true }: MaintenanceAlertProps) { export function MaintenanceAlert({ open, onOpenChange, closeable = true, accountId }: MaintenanceAlertProps) {
const returnUrl = typeof window !== 'undefined' ? window.location.href : '';
if (!open) return null;
// Filter plans to show only Pro and Enterprise
const premiumPlans = siteConfig.cloudPricingItems.filter(plan =>
plan.name === 'Pro' || plan.name === 'Enterprise'
);
return ( return (
<AlertDialog open={open} onOpenChange={closeable ? onOpenChange : undefined}> <Portal>
<AlertDialogContent className="max-w-2xl w-[90vw] p-0 border-0 shadow-lg overflow-hidden rounded-2xl z-[9999]"> <AnimatePresence>
<motion.div {open && (
className="relative" <>
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", damping: 30, stiffness: 300 }}
>
{/* Background pattern */}
<div className="absolute inset-0 bg-accent/20 opacity-20">
<div className="absolute inset-0 bg-grid-white/10 [mask-image:radial-gradient(white,transparent_85%)]" />
</div>
{closeable && (
<Button
variant="ghost"
size="icon"
className="absolute right-4 top-4 z-20 rounded-full hover:bg-background/80"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
)}
<AlertDialogHeader className="gap-6 px-8 pt-10 pb-6 relative z-10">
<motion.div <motion.div
className="flex items-center justify-center" initial={{ opacity: 0 }}
initial={{ y: -10, opacity: 0 }} animate={{ opacity: 1 }}
animate={{ y: 0, opacity: 1 }} exit={{ opacity: 0 }}
transition={{ delay: 0.1 }} transition={{ duration: 0.2 }}
className="fixed inset-0 z-[9999] flex items-center justify-center overflow-y-auto py-4"
> >
<div className="flex size-16 items-center justify-center rounded-full bg-gradient-to-t from-primary/20 to-secondary/10 backdrop-blur-md"> {/* Backdrop */}
<div className="flex size-12 items-center justify-center rounded-full bg-gradient-to-t from-primary to-primary/80 shadow-md"> <motion.div
<Clock className="h-6 w-6 text-white" /> initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
onClick={closeable ? () => onOpenChange(false) : undefined}
aria-hidden="true"
/>
{/* Modal */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className={cn(
"relative bg-background rounded-lg shadow-xl w-full max-w-md mx-3"
)}
role="dialog"
aria-modal="true"
aria-labelledby="free-tier-modal-title"
>
<div className="p-4">
{/* Close button */}
{closeable && (
<button
onClick={() => onOpenChange(false)}
className="absolute top-2 right-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close dialog"
>
<X className="h-4 w-4" />
</button>
)}
{/* Header */}
<div className="text-center mb-4">
<div className="inline-flex items-center justify-center p-1.5 bg-primary/10 rounded-full mb-2">
<Zap className="h-4 w-4 text-primary" />
</div>
<h2 id="free-tier-modal-title" className="text-lg font-medium tracking-tight mb-1">
Free Tier Unavailable At This Time
</h2>
<p className="text-xs text-muted-foreground">
Due to extremely high demand, we cannot offer a free tier at the moment. Upgrade to Pro to continue using our service.
</p>
</div>
{/* Custom plan comparison wrapper to show Pro, Enterprise and Self-Host side by side */}
<div className="grid grid-cols-3 gap-2 mb-3">
{premiumPlans.map((tier) => (
<div key={tier.name} className="border border-border rounded-lg p-3">
<div className="text-center mb-2">
<h3 className="font-medium">{tier.name}</h3>
<p className="text-sm font-bold">{tier.price}/mo</p>
<p className="text-xs text-muted-foreground">{tier.hours}/month</p>
</div>
<form>
<input type="hidden" name="accountId" value={accountId || ''} />
<input type="hidden" name="returnUrl" value={returnUrl} />
<input type="hidden" name="planId" value={
tier.name === 'Pro'
? siteConfig.cloudPricingItems.find(item => item.name === 'Pro')?.stripePriceId || ''
: siteConfig.cloudPricingItems.find(item => item.name === 'Enterprise')?.stripePriceId || ''
} />
<SubmitButton
pendingText="..."
formAction={setupNewSubscription}
className={cn(
"w-full font-medium transition-colors h-7 rounded-md text-xs",
tier.buttonColor
)}
>
Upgrade
</SubmitButton>
</form>
</div>
))}
{/* Self-host Option as the third card */}
<div className="border border-border rounded-lg p-3">
<div className="text-center mb-2">
<h3 className="font-medium">Self-Host</h3>
<p className="text-sm font-bold">Free</p>
<p className="text-xs text-muted-foreground">Open Source</p>
</div>
<Link
href="https://github.com/kortix-ai/suna"
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center justify-center gap-1 h-7 bg-gradient-to-tr from-primary to-primary/80 hover:opacity-90 text-white font-medium rounded-md text-xs transition-all"
>
<Github className="h-3.5 w-3.5" />
<span>Self-Host</span>
</Link>
</div>
</div>
</div> </div>
</div> </motion.div>
</motion.div> </motion.div>
</>
<motion.div )}
initial={{ y: -10, opacity: 0 }} </AnimatePresence>
animate={{ y: 0, opacity: 1 }} </Portal>
transition={{ delay: 0.2 }}
>
<AlertDialogTitle className="text-2xl font-bold text-center text-primary bg-clip-text">
High Demand Notice
</AlertDialogTitle>
</motion.div>
<motion.div
initial={{ y: -10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.3 }}
>
<AlertDialogDescription className="text-base text-center leading-relaxed">
Due to exceptionally high demand, our service is currently experiencing slower response times.
We recommend returning tomorrow when our systems will be operating at normal capacity.
<span className="mt-4 block font-medium text-primary">Thank you for your understanding. We will notify you via email once the service is fully operational again.</span>
</AlertDialogDescription>
</motion.div>
</AlertDialogHeader>
<AlertDialogFooter className="p-8 pt-4 border-t border-border/40 bg-background/40 backdrop-blur-sm">
<Link
href="https://github.com/kortix-ai/suna"
target="_blank"
rel="noopener noreferrer"
className="mx-auto w-full flex items-center justify-center gap-3 bg-gradient-to-tr from-primary to-primary/80 hover:opacity-90 text-white font-medium rounded-full px-8 py-3 transition-all hover:shadow-md"
>
<Github className="h-5 w-5 transition-transform group-hover:scale-110" />
<span>Explore Self-Hosted Version</span>
</Link>
</AlertDialogFooter>
</motion.div>
</AlertDialogContent>
</AlertDialog>
) )
} }

View File

@ -83,9 +83,9 @@ export const siteConfig = {
buttonText: "Hire Suna", buttonText: "Hire Suna",
buttonColor: "bg-secondary text-white", buttonColor: "bg-secondary text-white",
isPopular: false, isPopular: false,
hours: "10 minutes", hours: "no free usage at this time",
features: [ features: [
"10 minutes usage per month", "no free usage",
// "Community support", // "Community support",
// "Single user", // "Single user",
// "Standard response time", // "Standard response time",