mirror of https://github.com/kortix-ai/suna.git
Merge branch 'main' into fix_browser-use
This commit is contained in:
commit
059270ce6b
|
@ -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}")
|
||||||
|
|
|
@ -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 = []
|
||||||
|
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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') {
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in New Issue