This commit is contained in:
marko-kraemer 2025-04-23 05:14:43 +01:00
parent 0702c00cb8
commit 5a0e9f8a38
5 changed files with 358 additions and 104 deletions

View File

@ -372,6 +372,192 @@ async def start_agent(
return {"agent_run_id": agent_run_id, "status": "running"}
@router.post("/create-and-start")
async def create_project_and_start_agent(
request: Request,
user_id: str = Depends(get_current_user_id)
):
"""
Create a project, thread, and start an agent in a single atomic operation.
This prevents race conditions in sandbox creation.
"""
logger.info(f"Creating project and starting agent for user: {user_id}")
# Parse request body
body = await request.json()
# Extract parameters
project_name = body.get("name", "New Project")
project_description = body.get("description", "")
user_message = body.get("message", "")
model_name = body.get("model_name", "anthropic/claude-3-7-sonnet-latest")
enable_thinking = body.get("enable_thinking", False)
reasoning_effort = body.get("reasoning_effort", "low")
stream = body.get("stream", True)
enable_context_manager = body.get("enable_context_manager", False)
# Connect to the database
client = await db.client
# Check if the user has access to an account
user_accounts = await client.table('account_users').select('account_id').eq('user_id', user_id).execute()
if not user_accounts.data:
raise HTTPException(status_code=403, detail="User has no account access")
# Get the user's personal account (or first account if no personal account)
account_id = None
for acc in user_accounts.data:
account_data = await client.table('accounts').select('personal_account').eq('account_id', acc['account_id']).execute()
if account_data.data and account_data.data[0].get('personal_account'):
account_id = acc['account_id']
break
if not account_id:
# If no personal account, use first one
account_id = user_accounts.data[0]['account_id']
# Check billing status
can_run, message, subscription = await check_billing_status(client, account_id)
if not can_run:
raise HTTPException(status_code=402, detail={
"message": message,
"subscription": subscription
})
try:
# Create the project
project = await client.table('projects').insert({
"name": project_name,
"description": project_description,
"account_id": account_id,
"created_by": user_id,
"created_at": datetime.now(timezone.utc).isoformat()
}).execute()
project_id = project.data[0]['project_id']
logger.info(f"Created new project: {project_id}")
# Create a sandbox for the project (with race condition protection)
sandbox_pass = str(uuid.uuid4())
sandbox = create_sandbox(sandbox_pass)
logger.info(f"Created new sandbox for project {project_id} with preview: {sandbox.get_preview_link(6080)}/vnc_lite.html?password={sandbox_pass}")
sandbox_id = sandbox.id
# 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]
# Immediately update the project with sandbox info to avoid race conditions
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()
# Create a thread for the project
thread = await client.table('threads').insert({
"name": project_name,
"project_id": project_id,
"account_id": account_id,
"created_by": user_id,
"created_at": datetime.now(timezone.utc).isoformat()
}).execute()
thread_id = thread.data[0]['thread_id']
logger.info(f"Created new thread: {thread_id} for project: {project_id}")
# Add the user message to the thread
if user_message:
await client.table('messages').insert({
"thread_id": thread_id,
"role": "user",
"content": user_message,
"created_at": datetime.now(timezone.utc).isoformat()
}).execute()
logger.info(f"Added user message to thread: {thread_id}")
# Check if there is already an active agent run for this project
active_run_id = await check_for_active_project_agent_run(client, project_id)
# If there's an active run, stop it first
if active_run_id:
logger.info(f"Stopping existing agent run {active_run_id} before starting new one")
await stop_agent_run(active_run_id)
# Create a new agent run
agent_run = await client.table('agent_runs').insert({
"thread_id": thread_id,
"status": "running",
"started_at": datetime.now(timezone.utc).isoformat()
}).execute()
agent_run_id = agent_run.data[0]['id']
logger.info(f"Created new agent run: {agent_run_id}")
# Initialize in-memory storage for this agent run
active_agent_runs[agent_run_id] = []
# Register this run in Redis with TTL
try:
await redis.set(
f"active_run:{instance_id}:{agent_run_id}",
"running",
ex=redis.REDIS_KEY_TTL
)
except Exception as e:
logger.warning(f"Failed to register agent run in Redis, continuing without Redis tracking: {str(e)}")
# Run the agent in the background
task = asyncio.create_task(
run_agent_background(
agent_run_id=agent_run_id,
thread_id=thread_id,
instance_id=instance_id,
project_id=project_id,
sandbox=sandbox,
model_name=MODEL_NAME_ALIASES.get(model_name, model_name),
enable_thinking=enable_thinking,
reasoning_effort=reasoning_effort,
stream=stream,
enable_context_manager=enable_context_manager
)
)
# Set a callback to clean up when task is done
task.add_done_callback(
lambda _: asyncio.create_task(
_cleanup_agent_run(agent_run_id)
)
)
# Return all the created resources
return {
"project_id": project_id,
"thread_id": thread_id,
"agent_run_id": agent_run_id,
"status": "running"
}
except Exception as e:
logger.error(f"Error in create_project_and_start_agent: {str(e)}", exc_info=True)
# Re-raise as HTTP exception
raise HTTPException(status_code=500, detail=f"Failed to create project and start agent: {str(e)}")
@router.post("/agent-run/{agent_run_id}/stop")
async def stop_agent(agent_run_id: str, user_id: str = Depends(get_current_user_id)):
"""Stop a running agent."""

View File

@ -5,6 +5,7 @@ WORKDIR /app
# Copy requirements first for better layer caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir gunicorn uvicorn
# Copy the backend code
COPY . .
@ -12,8 +13,26 @@ COPY . .
# Set environment variable
ENV PYTHONPATH=/app
# Default environment variables
# These should be overridden at runtime with actual values
ENV DAYTONA_API_KEY=""
ENV DAYTONA_SERVER_URL=""
ENV DAYTONA_TARGET=""
ENV ANTHROPIC_API_KEY=""
ENV OPENAI_API_KEY=""
ENV MODEL_TO_USE=""
ENV SUPABASE_URL=""
ENV SUPABASE_ANON_KEY=""
ENV SUPABASE_SERVICE_ROLE_KEY=""
ENV REDIS_HOST=""
ENV REDIS_PORT=""
ENV REDIS_PASSWORD=""
ENV REDIS_SSL=""
# Expose the port the app runs on
EXPOSE 8000
# Command to run the application
CMD ["python", "api.py"]
# Command to run the application with Uvicorn directly
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "32", "--log-level", "info", "--timeout-keep-alive", "500", "--proxy-headers", "--forwarded-allow-ips", "*"]

View File

@ -5,7 +5,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useRouter } from 'next/navigation';
import { Menu } from "lucide-react";
import { ChatInput } from '@/components/thread/chat-input';
import { createProject, addUserMessage, startAgent, createThread } from "@/lib/api";
import { createProject, addUserMessage, startAgent, createThread, createProjectAndStartAgent } from "@/lib/api";
import { generateThreadName } from "@/lib/actions/threads";
import { useIsMobile } from "@/hooks/use-mobile";
import { useSidebar } from "@/components/ui/sidebar";
@ -22,6 +22,7 @@ function DashboardContent() {
const [inputValue, setInputValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [autoSubmit, setAutoSubmit] = useState(false);
const [isSubmitDisabled, setIsSubmitDisabled] = useState(false);
const { billingError, handleBillingError, clearBillingError } = useBillingError();
const router = useRouter();
const isMobile = useIsMobile();
@ -30,128 +31,134 @@ function DashboardContent() {
const personalAccount = accounts?.find(account => account.personal_account);
const handleSubmit = async (message: string, options?: { model_name?: string; enable_thinking?: boolean }) => {
if (!message.trim() || isSubmitting) return;
if (!message.trim() || isSubmitting || isSubmitDisabled) return;
setIsSubmitting(true);
setIsSubmitDisabled(true); // Disable submit button immediately
try {
// Generate a name for the project using GPT
const projectName = await generateThreadName(message);
// 1. Create a new project with the GPT-generated name
const newAgent = await createProject({
// Use the new unified API endpoint to create project, thread, and start agent in one call
const result = await createProjectAndStartAgent({
name: projectName,
description: "",
message: message.trim(),
model_name: options?.model_name,
enable_thinking: options?.enable_thinking,
stream: true
});
// 2. Create a new thread for this project
const thread = await createThread(newAgent.id);
// 3. Add the user message to the thread
await addUserMessage(thread.thread_id, message.trim());
// If successful, clear the pending prompt
localStorage.removeItem(PENDING_PROMPT_KEY);
try {
// 4. Start the agent with the thread ID
const agentRun = await startAgent(thread.thread_id, {
model_name: options?.model_name,
enable_thinking: options?.enable_thinking,
stream: true
});
// Navigate to the new agent's thread page
router.push(`/agents/${result.thread_id}`);
} catch (error: any) {
// Check specifically for billing errors (402 Payment Required)
if (error.message?.includes('(402)') || error?.status === 402) {
console.log("Billing error detected:", error);
// If successful, clear the pending prompt
localStorage.removeItem(PENDING_PROMPT_KEY);
// 5. Navigate to the new agent's thread page
router.push(`/agents/${thread.thread_id}`);
} catch (error: any) {
// Check specifically for billing errors (402 Payment Required)
if (error.message?.includes('(402)') || error?.status === 402) {
console.log("Billing error detected:", error);
// Try to extract the error details from the error object
try {
// Try to parse the error.response or the error itself
let errorDetails;
// Try to extract the error details from the error object
try {
// Try to parse the error.response or the error itself
let errorDetails;
// First attempt: check if error.data exists and has a detail property
if (error.data?.detail) {
errorDetails = error.data.detail;
console.log("Extracted billing error details from error.data.detail:", errorDetails);
}
// Second attempt: check if error.detail exists directly
else if (error.detail) {
errorDetails = error.detail;
console.log("Extracted billing error details from error.detail:", errorDetails);
// First attempt: check if error.data exists and has a detail property
if (error.data?.detail) {
errorDetails = error.data.detail;
console.log("Extracted billing error details from error.data.detail:", errorDetails);
}
// Second attempt: check if error.detail exists directly
else if (error.detail) {
errorDetails = error.detail;
console.log("Extracted billing error details from error.detail:", errorDetails);
}
// Third attempt: try to parse the error text if it's JSON
else if (typeof error.text === 'function') {
const text = await error.text();
console.log("Extracted error text:", text);
try {
const parsed = JSON.parse(text);
errorDetails = parsed.detail || parsed;
console.log("Parsed error text as JSON:", errorDetails);
} catch (e) {
// Not JSON, use regex to extract info
console.log("Error text is not valid JSON");
}
// Third attempt: try to parse the error text if it's JSON
else if (typeof error.text === 'function') {
const text = await error.text();
console.log("Extracted error text:", text);
try {
const parsed = JSON.parse(text);
errorDetails = parsed.detail || parsed;
console.log("Parsed error text as JSON:", errorDetails);
} catch (e) {
// Not JSON, use regex to extract info
console.log("Error text is not valid JSON");
}
}
// If we still don't have details, try to extract from the error message
if (!errorDetails && error.message) {
const match = error.message.match(/Monthly limit of (\d+) minutes reached/);
if (match) {
const minutes = parseInt(match[1]);
errorDetails = {
message: error.message,
subscription: {
price_id: "price_1RGJ9GG6l1KZGqIroxSqgphC", // Free tier by default
plan_name: "Free",
current_usage: minutes / 60, // Convert to hours
limit: minutes / 60 // Convert to hours
}
};
console.log("Extracted billing error details from error message:", errorDetails);
}
}
// Handle the billing error with the details we extracted
if (errorDetails) {
console.log("Handling billing error with extracted details:", errorDetails);
handleBillingError(errorDetails);
} else {
// Fallback with generic billing error
console.log("Using fallback generic billing error");
handleBillingError({
message: "You've reached your monthly usage limit. Please upgrade your plan.",
subscription: {
price_id: "price_1RGJ9GG6l1KZGqIroxSqgphC", // Free tier
plan_name: "Free"
}
});
}
} catch (parseError) {
console.error("Error parsing billing error details:", parseError);
// Fallback with generic error
handleBillingError({
message: "You've reached your monthly usage limit. Please upgrade your plan."
});
}
// Don't rethrow - we've handled this error with the billing alert
setIsSubmitting(false);
return; // Exit handleSubmit
// If we still don't have details, try to extract from the error message
if (!errorDetails && error.message) {
const match = error.message.match(/Monthly limit of (\d+) minutes reached/);
if (match) {
const minutes = parseInt(match[1]);
errorDetails = {
message: error.message,
subscription: {
price_id: "price_1RGJ9GG6l1KZGqIroxSqgphC", // Free tier by default
plan_name: "Free",
current_usage: minutes / 60, // Convert to hours
limit: minutes / 60 // Convert to hours
}
};
console.log("Extracted billing error details from error message:", errorDetails);
}
}
// Handle the billing error with the details we extracted
if (errorDetails) {
console.log("Handling billing error with extracted details:", errorDetails);
handleBillingError(errorDetails);
} else {
// Fallback with generic billing error
console.log("Using fallback generic billing error");
handleBillingError({
message: "You've reached your monthly usage limit. Please upgrade your plan.",
subscription: {
price_id: "price_1RGJ9GG6l1KZGqIroxSqgphC", // Free tier
plan_name: "Free"
}
});
}
} catch (parseError) {
console.error("Error parsing billing error details:", parseError);
// Fallback with generic error
handleBillingError({
message: "You've reached your monthly usage limit. Please upgrade your plan."
});
}
// Rethrow any non-billing errors
throw error;
// Don't rethrow - we've handled this error with the billing alert
setIsSubmitting(false);
setIsSubmitDisabled(false); // Re-enable submit button on error
return; // Exit handleSubmit
}
// Rethrow any non-billing errors
throw error;
} catch (error) {
console.error("Error creating agent:", error);
setIsSubmitting(false);
setIsSubmitDisabled(false); // Re-enable submit button on error
}
};
// Reset the submit disabled state after 2 seconds to prevent accidental double submission
useEffect(() => {
let timer: NodeJS.Timeout;
if (isSubmitDisabled) {
timer = setTimeout(() => {
setIsSubmitDisabled(false);
}, 2000);
}
return () => {
if (timer) clearTimeout(timer);
};
}, [isSubmitDisabled]);
// Check for pending prompt in localStorage on mount
useEffect(() => {
// Use a small delay to ensure we're fully mounted
@ -250,3 +257,4 @@ export default function DashboardPage() {
</Suspense>
);
}

View File

@ -15,6 +15,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Clock } from "lucide-react"
interface DashboardLayoutProps {
children: React.ReactNode
@ -40,15 +41,20 @@ export default function DashboardLayout({
</SidebarInset>
<AlertDialog open={showMaintenanceAlert} onOpenChange={setShowMaintenanceAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>System Issues</AlertDialogTitle>
<AlertDialogContent className="border border-muted">
<AlertDialogHeader className="gap-3">
<div className="flex items-center justify-center">
<Clock className="h-8 w-8 text-muted-foreground" />
</div>
<AlertDialogTitle className="text-lg font-medium">High Demand Notice</AlertDialogTitle>
<AlertDialogDescription>
We're currently experiencing technical issues with our service. We apologize for the inconvenience.
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.
<p className="mt-2">Thank you for your understanding.</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction>Understood</AlertDialogAction>
<AlertDialogAction>I'll Return Tomorrow</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@ -1039,3 +1039,38 @@ export const getPublicProjects = async (): Promise<Project[]> => {
}
};
export async function createProjectAndStartAgent(data: {
name: string;
description: string;
message: string;
model_name?: string;
enable_thinking?: boolean;
reasoning_effort?: string;
stream?: boolean;
enable_context_manager?: boolean;
}) {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('No access token available');
}
const response = await fetch(`${API_URL}/agent/create-and-start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.text();
console.error("Error creating project and starting agent:", error);
throw new Error(`Failed to create project and start agent (${response.status}): ${error}`);
}
return await response.json();
}