This commit is contained in:
Adam Cohen Hillel 2025-04-12 01:04:40 +01:00
parent 4e5ad7ab64
commit a6d2e5b18e
16 changed files with 2262 additions and 356 deletions

View File

@ -174,7 +174,7 @@ async def check_for_active_project_agent_run(client, project_id: str):
async def get_agent_run_with_access_check(client, agent_run_id: str, user_id: str):
"""
Get an agent run's data after verifying the user has access to it.
Get an agent run's data after verifying the user has access to it through account membership.
Args:
client: The Supabase client
@ -195,10 +195,10 @@ async def get_agent_run_with_access_check(client, agent_run_id: str, user_id: st
agent_run_data = agent_run.data[0]
thread_id = agent_run_data['thread_id']
# Verify user has access to this thread
# Verify user has access to this thread using the updated verify_thread_access function
await verify_thread_access(client, thread_id, user_id)
return agent_run_data
return agent_run_data
async def _cleanup_agent_run(agent_run_id: str):
"""Clean up Redis keys when an agent run is done."""
@ -219,12 +219,13 @@ async def start_agent(thread_id: str, user_id: str = Depends(get_current_user_id
# Verify user has access to this thread
await verify_thread_access(client, thread_id, user_id)
# Get the project_id for this thread
thread_result = await client.table('threads').select('project_id').eq('thread_id', thread_id).execute()
# Get the project_id and account_id for this thread
thread_result = await client.table('threads').select('project_id', 'account_id').eq('thread_id', thread_id).execute()
if not thread_result.data:
raise HTTPException(status_code=404, detail="Thread not found")
project_id = thread_result.data[0]['project_id']
thread_data = thread_result.data[0]
project_id = thread_data.get('project_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)

View File

@ -24,24 +24,27 @@ async def run_agent(thread_id: str, project_id: str, stream: bool = True, thread
client = await thread_manager.db.client
## probably want to move to api.py
project = await client.table('projects').select('*').eq('project_id', project_id).execute()
if project.data and project.data[0]['sandbox_id'] is not None:
sandbox_id = project.data[0]['sandbox_id']
sandbox_pass = project.data[0]['sandbox_pass']
if project.data[0].get('sandbox', {}).get('id'):
sandbox_id = project.data[0]['sandbox']['id']
sandbox_pass = project.data[0]['sandbox']['pass']
sandbox = await get_or_start_sandbox(sandbox_id)
else:
sandbox_pass = str(uuid4())
sandbox = create_sandbox(sandbox_pass)
sandbox_id = sandbox.id
await client.table('projects').update({
'sandbox_id': sandbox_id,
'sandbox_pass': sandbox_pass
'sandbox': {
'id': sandbox_id,
'pass': sandbox_pass
}
}).eq('project_id', project_id).execute()
thread_manager.add_tool(SandboxShellTool, sandbox_id=sandbox_id, password=sandbox_pass)
thread_manager.add_tool(SandboxFilesTool, sandbox_id=sandbox_id, password=sandbox_pass)
thread_manager.add_tool(WebSearchTool)
# thread_manager.add_tool(SandboxBrowseTool, sandbox=sandbox)
thread_manager.add_tool(SandboxShellTool, sandbox=sandbox)
thread_manager.add_tool(SandboxFilesTool, sandbox=sandbox)
thread_manager.add_tool(MessageTool)
thread_manager.add_tool(SandboxDeployTool, sandbox_id=sandbox_id, password=sandbox_pass)
thread_manager.add_tool(WebSearchTool)
thread_manager.add_tool(SandboxDeployTool, sandbox=sandbox)
system_message = { "role": "system", "content": get_system_prompt() }
@ -159,7 +162,21 @@ async def test_agent():
client = await DBConnection().client
try:
project_result = await client.table('projects').select('*').eq('name', 'test11').eq('user_id', '68e1da55-0749-49db-937a-ff56bf0269a0').execute()
# Get user's personal account
account_result = await client.rpc('get_personal_account').execute()
# if not account_result.data:
# print("Error: No personal account found")
# return
account_id = "a5fe9cb6-4812-407e-a61c-fe95b7320c59"
if not account_id:
print("Error: Could not get account ID")
return
# Find or create a test project in the user's account
project_result = await client.table('projects').select('*').eq('name', 'test11').eq('account_id', account_id).execute()
if project_result.data and len(project_result.data) > 0:
# Use existing test project
@ -167,11 +184,18 @@ async def test_agent():
print(f"\n🔄 Using existing test project: {project_id}")
else:
# Create new test project if none exists
project_result = await client.table('projects').insert({"name": "test11", "user_id": "68e1da55-0749-49db-937a-ff56bf0269a0"}).execute()
project_result = await client.table('projects').insert({
"name": "test11",
"account_id": account_id
}).execute()
project_id = project_result.data[0]['project_id']
print(f"\n✨ Created new test project: {project_id}")
thread_result = await client.table('threads').insert({'project_id': project_id}).execute()
# Create a thread for this project
thread_result = await client.table('threads').insert({
'project_id': project_id,
'account_id': account_id
}).execute()
thread_data = thread_result.data[0] if thread_result.data else None
if not thread_data:

View File

@ -1,7 +1,7 @@
import os
from dotenv import load_dotenv
from agentpress.tool import ToolResult, openapi_schema, xml_schema
from sandbox.sandbox import SandboxToolsBase
from sandbox.sandbox import SandboxToolsBase, Sandbox
from utils.files_utils import clean_path
from agent.tools.sb_shell_tool import SandboxShellTool
@ -11,11 +11,11 @@ load_dotenv()
class SandboxDeployTool(SandboxToolsBase):
"""Tool for deploying static websites from a Daytona sandbox to Cloudflare Pages."""
def __init__(self, sandbox_id: str, password: str):
super().__init__(sandbox_id, password)
def __init__(self, sandbox: Sandbox):
super().__init__(sandbox)
self.workspace_path = "/workspace" # Ensure we're always operating in /workspace
self.cloudflare_api_token = os.getenv("CLOUDFLARE_API_TOKEN")
self.shell_tool = SandboxShellTool(sandbox_id, password)
self.shell_tool = SandboxShellTool(sandbox)
def clean_path(self, path: str) -> str:
"""Clean and normalize a path to be relative to /workspace"""

View File

@ -277,26 +277,33 @@ GRANT ALL PRIVILEGES ON TABLE agent_runs TO authenticated, service_role;
-- Create a function that matches the Python get_messages behavior
CREATE OR REPLACE FUNCTION get_llm_formatted_messages(p_thread_id UUID)
RETURNS JSONB
SECURITY INVOKER
SECURITY DEFINER -- Changed to SECURITY DEFINER to allow service role access
LANGUAGE plpgsql
AS $$
DECLARE
messages_array JSONB := '[]'::JSONB;
has_access BOOLEAN;
current_role TEXT;
BEGIN
-- Check if thread exists and user has access
SELECT EXISTS (
SELECT 1 FROM threads t
LEFT JOIN projects p ON t.project_id = p.project_id
WHERE t.thread_id = p_thread_id
AND (
basejump.has_role_on_account(t.account_id) = true OR
basejump.has_role_on_account(p.account_id) = true
)
) INTO has_access;
-- Get current role
SELECT current_user INTO current_role;
IF NOT has_access THEN
RAISE EXCEPTION 'Thread not found or access denied';
-- Skip access check for service_role
IF current_role = 'authenticated' THEN
-- Check if thread exists and user has access
SELECT EXISTS (
SELECT 1 FROM threads t
LEFT JOIN projects p ON t.project_id = p.project_id
WHERE t.thread_id = p_thread_id
AND (
basejump.has_role_on_account(t.account_id) = true OR
basejump.has_role_on_account(p.account_id) = true
)
) INTO has_access;
IF NOT has_access THEN
RAISE EXCEPTION 'Thread not found or access denied';
END IF;
END IF;
-- Parse content if it's stored as a string and return proper JSON objects

View File

@ -1,7 +1,8 @@
from fastapi import HTTPException, Request, Depends
from typing import Optional
from typing import Optional, List, Dict, Any
import jwt
from jwt.exceptions import PyJWTError
from utils.logger import logger
# This function extracts the user ID from Supabase JWT
async def get_current_user_id(request: Request) -> str:
@ -107,7 +108,7 @@ async def get_user_id_from_stream_auth(
async def verify_thread_access(client, thread_id: str, user_id: str):
"""
Verify that a user has access to a specific thread.
Verify that a user has access to a specific thread based on account membership.
Args:
client: The Supabase client
@ -120,9 +121,17 @@ async def verify_thread_access(client, thread_id: str, user_id: str):
Raises:
HTTPException: If the user doesn't have access to the thread
"""
thread = await client.table('threads').select('thread_id').eq('thread_id', thread_id).eq('user_id', user_id).execute()
# Query the thread to get account information
thread_result = await client.table('threads').select('*').eq('thread_id', thread_id).execute()
if not thread_result.data or len(thread_result.data) == 0:
raise HTTPException(status_code=404, detail="Thread not found")
if not thread.data or len(thread.data) == 0:
raise HTTPException(status_code=403, detail="Not authorized to access this thread")
return True
thread_data = thread_result.data[0]
account_id = thread_data.get('account_id')
# When using service role, we need to manually check account membership instead of using current_user_account_role
if account_id:
account_user_result = await client.schema('basejump').from_('account_user').select('account_role').eq('user_id', user_id).eq('account_id', account_id).execute()
if account_user_result.data and len(account_user_result.data) > 0:
return True
raise HTTPException(status_code=403, detail="Not authorized to access this thread")

View File

@ -1,5 +1,5 @@
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {

View File

@ -0,0 +1,353 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from "react";
import { getProject, getMessages, getThread, addUserMessage, startAgent, stopAgent, getAgentRuns, createThread, type Message, type Project, type Thread, type AgentRun } from "@/lib/api";
import { useRouter, useSearchParams } from "next/navigation";
import { AlertCircle, ArrowDown, Play, Square } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
interface AgentPageProps {
params: {
threadId: string;
};
}
// Simple component to handle message formatting
function MessageContent({ content }: { content: string }) {
return (
<div className="whitespace-pre-wrap">
{content.split('\n').map((line, i) => (
<React.Fragment key={i}>
{line}
{i < content.split('\n').length - 1 && <br />}
</React.Fragment>
))}
</div>
);
}
export default function AgentPage({ params }: AgentPageProps) {
const { threadId } = params;
const router = useRouter();
const searchParams = useSearchParams();
const initialMessage = searchParams.get('message');
const messagesEndRef = useRef<HTMLDivElement>(null);
const [agent, setAgent] = useState<Project | null>(null);
const [conversation, setConversation] = useState<Thread | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [agentRuns, setAgentRuns] = useState<AgentRun[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [userMessage, setUserMessage] = useState("");
const [isSending, setIsSending] = useState(false);
// Scroll to bottom of messages
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
// Load initial data
useEffect(() => {
async function loadData() {
setIsLoading(true);
setError(null);
try {
// Check if we're creating a new conversation or using an existing one
if (threadId === 'new') {
try {
// For new threads, we need a project ID - we could redirect to project selection
// or use a default project (future enhancement)
router.push('/dashboard');
return;
} catch (err) {
console.error("Failed to create new thread:", err);
setError("Failed to create a new conversation");
}
} else {
// Load existing conversation (thread) data
const conversationData = await getThread(threadId);
setConversation(conversationData);
if (conversationData && conversationData.project_id) {
// Load agent (project) data
const agentData = await getProject(conversationData.project_id);
setAgent(agentData);
// Only load messages and agent runs if we have a valid thread
const messagesData = await getMessages(threadId);
setMessages(messagesData);
const agentRunsData = await getAgentRuns(threadId);
setAgentRuns(agentRunsData);
}
}
} catch (err: any) {
console.error("Error loading conversation data:", err);
// Handle permission errors specifically
if (err.code === '42501' && err.message?.includes('has_role_on_account')) {
setError("You don't have permission to access this conversation");
} else {
setError(err instanceof Error ? err.message : "An error occurred loading the conversation");
}
} finally {
setIsLoading(false);
}
}
loadData();
}, [threadId, initialMessage, router]);
// Scroll to bottom when messages change
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
// Check for agent status periodically if an agent is running
useEffect(() => {
if (!conversation) return;
const isAgentRunning = agentRuns.some(run => run.status === "running");
if (!isAgentRunning) return;
// Poll for updates every 3 seconds if agent is running
const interval = setInterval(async () => {
try {
const updatedAgentRuns = await getAgentRuns(conversation.thread_id);
setAgentRuns(updatedAgentRuns);
// Also refresh messages
const updatedMessages = await getMessages(conversation.thread_id);
setMessages(updatedMessages);
// If no agent is running anymore, stop polling
if (!updatedAgentRuns.some(run => run.status === "running")) {
clearInterval(interval);
}
} catch (err) {
console.error("Error polling agent status:", err);
}
}, 3000);
return () => clearInterval(interval);
}, [agentRuns, conversation]);
// Handle sending a message
const handleSendMessage = async () => {
if (!userMessage.trim() || isSending) return;
if (!conversation) return;
setIsSending(true);
try {
// Add user message
await addUserMessage(conversation.thread_id, userMessage);
// Start the agent
await startAgent(conversation.thread_id);
// Clear the input
setUserMessage("");
// Refresh data
const updatedMessages = await getMessages(conversation.thread_id);
setMessages(updatedMessages);
const updatedAgentRuns = await getAgentRuns(conversation.thread_id);
setAgentRuns(updatedAgentRuns);
} catch (err) {
console.error("Error sending message:", err);
setError(err instanceof Error ? err.message : "Failed to send message");
} finally {
setIsSending(false);
}
};
// Handle starting the agent
const handleRunAgent = async () => {
if (isSending || !conversation) return;
setIsSending(true);
try {
await startAgent(conversation.thread_id);
// Refresh agent runs
const updatedAgentRuns = await getAgentRuns(conversation.thread_id);
setAgentRuns(updatedAgentRuns);
} catch (err) {
console.error("Error starting agent:", err);
setError(err instanceof Error ? err.message : "Failed to start agent");
} finally {
setIsSending(false);
}
};
// Handle stopping the agent
const handleStopAgent = async () => {
try {
// Find the running agent run
const runningAgent = agentRuns.find(run => run.status === "running");
if (runningAgent) {
await stopAgent(runningAgent.id);
// Refresh agent runs
if (conversation) {
const updatedAgentRuns = await getAgentRuns(conversation.thread_id);
setAgentRuns(updatedAgentRuns);
}
}
} catch (err) {
console.error("Error stopping agent:", err);
setError(err instanceof Error ? err.message : "Failed to stop agent");
}
};
// Check if agent is running
const isAgentRunning = agentRuns.some(run => run.status === "running");
if (error) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button variant="outline" onClick={() => router.push(`/dashboard/agent`)}>
Back to Agents
</Button>
</div>
);
}
if (isLoading || (!agent && threadId !== 'new')) {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<Skeleton className="h-7 w-48" />
<Skeleton className="h-5 w-64 mt-2" />
</div>
</div>
<div className="space-y-4 mt-8">
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
<Skeleton className="h-12 w-full mt-4" />
</div>
</div>
);
}
return (
<div className="flex flex-col h-[calc(100vh-10rem)] max-h-[calc(100vh-10rem)]">
<div className="flex-1 overflow-y-auto p-4 space-y-4" id="messages-container">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<h3 className="text-lg font-medium">Start a conversation</h3>
<p className="text-sm text-muted-foreground mt-2 mb-4">
Send a message to start talking with {agent?.name || "the AI agent"}
</p>
</div>
</div>
) : (
<>
{messages.map((message, index) => (
<div
key={index}
className={`flex ${
message.role === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[80%] p-4 rounded-lg ${
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
}`}
>
<MessageContent content={message.content} />
</div>
</div>
))}
{/* Show a loading indicator if the agent is running */}
{isAgentRunning && (
<div className="flex justify-start">
<div className="max-w-[80%] p-4 rounded-lg bg-muted">
<div className="flex items-center space-x-2">
<div className="h-2 w-2 bg-foreground rounded-full animate-pulse"></div>
<div className="h-2 w-2 bg-foreground rounded-full animate-pulse delay-150"></div>
<div className="h-2 w-2 bg-foreground rounded-full animate-pulse delay-300"></div>
<span className="text-sm text-muted-foreground ml-2">AI is thinking...</span>
</div>
</div>
</div>
)}
</>
)}
<div ref={messagesEndRef} id="messages-end"></div>
</div>
<div className="p-4 border-t">
<div className="flex flex-col space-y-2">
<div className="flex-1">
<Textarea
value={userMessage}
onChange={(e) => setUserMessage(e.target.value)}
placeholder="Type your message..."
className="resize-none min-h-[80px]"
disabled={isAgentRunning || isSending || !conversation}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.shiftKey === false) {
e.preventDefault();
handleSendMessage();
}
}}
/>
</div>
<div className="flex justify-between items-center">
<div>
{isAgentRunning ? (
<Button
onClick={handleStopAgent}
variant="outline"
size="sm"
>
<Square className="h-4 w-4 mr-2" />
Stop Agent
</Button>
) : (
<Button
onClick={handleRunAgent}
variant="outline"
size="sm"
disabled={messages.length === 0 || isSending || !conversation}
>
<Play className="h-4 w-4 mr-2" />
Run Agent
</Button>
)}
</div>
<Button
onClick={handleSendMessage}
className="ml-2"
disabled={!userMessage.trim() || isAgentRunning || isSending || !conversation}
>
Send Message
{isSending && <span className="ml-2">...</span>}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,151 @@
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { PlusCircle, MessagesSquare, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { getProjects, getThreads, type Project } from "@/lib/api";
// Define the Agent type that combines project and thread data
interface Agent {
id: string;
name: string;
description: string;
created_at: string;
threadId: string | null;
}
export default function AgentsPage() {
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadAgents() {
setIsLoading(true);
try {
// Get projects from API - in our new model, each project is an agent with one thread
const projectsData = await getProjects();
// We'll fetch threads for each project to create our agent abstraction
const agentsData: Agent[] = [];
for (const project of projectsData) {
// For each project, get its threads
const threads = await getThreads(project.id);
// Create an agent entry with the first thread (or null if none exists)
agentsData.push({
id: project.id,
name: project.name,
description: project.description,
created_at: project.created_at,
threadId: threads && threads.length > 0 ? threads[0].thread_id : null,
});
}
setAgents(agentsData);
} catch (err) {
console.error("Error loading agents:", err);
setError(err instanceof Error ? err.message : "An error occurred loading agents");
} finally {
setIsLoading(false);
}
}
loadAgents();
}, []);
if (error) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Your Agents</h1>
<p className="text-muted-foreground mt-2">
Create and manage your AI agents
</p>
</div>
<Button asChild>
<Link href="/dashboard/agents/new">
<PlusCircle className="mr-2 h-4 w-4" />
New Agent
</Link>
</Button>
</div>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="p-4 border rounded-md">
<div className="flex flex-col space-y-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
<div className="flex justify-between items-center pt-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-9 w-36" />
</div>
</div>
</div>
))}
</div>
) : agents.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 text-center border rounded-md">
<MessagesSquare className="h-12 w-12 text-muted-foreground mb-4" />
<h2 className="text-xl font-semibold mb-2">No agents yet</h2>
<p className="text-muted-foreground max-w-md mb-4">
Create your first agent to start automating tasks and getting help from AI.
</p>
<Button asChild>
<Link href="/dashboard/agents/new">
<PlusCircle className="mr-2 h-4 w-4" />
Create your first agent
</Link>
</Button>
</div>
) : (
<div className="space-y-3">
{agents.map((agent) => (
<div key={agent.id} className="p-4 border rounded-md hover:bg-muted/50 transition-colors">
<div className="flex flex-col">
<h3 className="font-medium">{agent.name}</h3>
<p className="text-sm text-muted-foreground truncate mb-3">
{agent.description || "No description provided"}
</p>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">
{new Date(agent.created_at).toLocaleDateString()}
</span>
<Button asChild variant="outline" size="sm">
<Link
href={
agent.threadId
? `/dashboard/agents/${agent.threadId}`
: `/dashboard`
}
>
Continue Conversation
</Link>
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -1,70 +1,81 @@
import {createClient} from "@/lib/supabase/server";
import DashboardLayout from "@/components/layout/DashboardLayout";
import {Home, Smartphone, Video, Settings} from "lucide-react";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export default async function PersonalAccountDashboard({children}: {children: React.ReactNode}) {
export default async function PersonalDashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// Get the current user via Supabase
const supabaseClient = createClient();
const { data: { user } } = await supabaseClient.auth.getUser();
// Redirect if not logged in
if (!user) {
redirect("/login");
}
// Get the personal account details
const { data: personalAccount } = await supabaseClient.rpc('get_personal_account');
const supabaseClient = createClient();
// Define the navigation items for our agent-based UI
const navigation = [
{
name: "Home",
href: "/dashboard",
},
{
name: "Agents",
href: "/dashboard/agents",
},
{
name: "Devices",
href: "/dashboard/devices",
},
{
name: "Data",
href: "/dashboard/data",
},
{
name: "Settings",
href: "/dashboard/settings",
},
];
const {data: personalAccount, error} = await supabaseClient.rpc('get_personal_account');
const navigation = [
{
name: 'Overview',
href: '/dashboard',
icon: <Home size={20} />,
},
{
name: 'Devices',
href: '/dashboard/devices',
icon: <Smartphone size={20} />,
},
{
name: 'Recordings',
href: '/dashboard/recordings',
icon: <Video size={20} />,
},
{
name: 'Settings',
href: '/dashboard/settings',
icon: <Settings size={20} />,
}
]
// Right panel content - replace with your actual content
const rightPanelContent = (
<div className="flex flex-col gap-4">
<div className="p-4 border rounded-lg">
<h3 className="font-medium mb-2">Account Status</h3>
<p className="text-sm text-muted-foreground">Active</p>
</div>
<div className="p-4 border rounded-lg">
<h3 className="font-medium mb-2">Recent Activity</h3>
<ul className="space-y-2 text-sm">
<li className="flex items-center justify-between">
<span>Login</span>
<span className="text-muted-foreground">1 hour ago</span>
</li>
<li className="flex items-center justify-between">
<span>Settings updated</span>
<span className="text-muted-foreground">3 days ago</span>
</li>
</ul>
</div>
</div>
);
return (
<DashboardLayout
navigation={navigation}
accountId={personalAccount.account_id}
userName={personalAccount.name}
userEmail={personalAccount.email}
rightPanelContent={rightPanelContent}
rightPanelTitle="Account Details"
>
{children}
</DashboardLayout>
)
// Right panel content
const rightPanelContent = (
<div className="flex flex-col gap-4">
<div className="p-4 border rounded-lg">
<h3 className="font-medium mb-2">Account Status</h3>
<p className="text-sm text-muted-foreground">Active</p>
</div>
<div className="p-4 border rounded-lg">
<h3 className="font-medium mb-2">Recent Activity</h3>
<ul className="space-y-2 text-sm">
<li className="flex items-center justify-between">
<span>Login</span>
<span className="text-muted-foreground">1 hour ago</span>
</li>
<li className="flex items-center justify-between">
<span>Settings updated</span>
<span className="text-muted-foreground">3 days ago</span>
</li>
</ul>
</div>
</div>
);
return (
<DashboardLayout
navigation={navigation}
accountId={personalAccount?.account_id || user.id}
userName={personalAccount?.name || user.email?.split("@")[0] || "User"}
userEmail={personalAccount?.email || user.email}
rightPanelContent={rightPanelContent}
rightPanelTitle="Account Details"
>
{children}
</DashboardLayout>
);
}

View File

@ -1,11 +1,85 @@
import { PartyPopper } from "lucide-react";
"use client";
export default function PersonalAccountPage() {
return (
<div className="flex flex-col gap-y-4 py-12 h-full w-full items-center justify-center content-center max-w-screen-md mx-auto text-center">
<PartyPopper className="h-12 w-12 text-gray-400" />
<h1 className="text-2xl font-bold">Personal Account</h1>
<p>Here's where you'll put all your awesome personal account items. If you only want to support team accounts, you can just remove these pages</p>
import React, { useState, Suspense } from 'react';
import { Skeleton } from "@/components/ui/skeleton";
import { useRouter } from 'next/navigation';
import { ChatInput } from '@/components/chat/chat-input';
import { createProject, addUserMessage, startAgent, createThread } from "@/lib/api";
function DashboardContent() {
const [inputValue, setInputValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const router = useRouter();
const handleSubmit = async (message: string) => {
if (!message.trim() || isSubmitting) return;
setIsSubmitting(true);
try {
// 1. Create a new project with the message as the name
const newAgent = await createProject({
name: message.trim().length > 50
? message.trim().substring(0, 47) + "..."
: message.trim(),
description: "",
});
// 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());
// 4. Start the agent with the thread ID
const agentRun = await startAgent(thread.thread_id);
// 5. Navigate to the new agent's thread page
router.push(`/dashboard/agents/${thread.thread_id}`);
} catch (error) {
console.error("Error creating agent:", error);
setIsSubmitting(false);
}
};
return (
<div className="flex flex-col items-center justify-center h-full w-full">
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[560px] max-w-[90%]">
<div className="text-center mb-10">
<h1 className="text-4xl font-medium text-foreground mb-2">Hello.</h1>
<h2 className="text-2xl text-muted-foreground">What can I help with?</h2>
</div>
)
<ChatInput
onSubmit={handleSubmit}
loading={isSubmitting}
placeholder="Ask anything..."
value={inputValue}
onChange={setInputValue}
/>
</div>
</div>
);
}
export default function DashboardPage() {
return (
<Suspense fallback={
<div className="flex flex-col items-center justify-center h-full w-full">
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-[560px] max-w-[90%]">
<div className="flex flex-col items-center text-center mb-10">
<Skeleton className="h-10 w-40 mb-2" />
<Skeleton className="h-7 w-56" />
</div>
<Skeleton className="w-full h-[100px] rounded-xl" />
<div className="flex justify-center mt-3">
<Skeleton className="h-5 w-16" />
</div>
</div>
</div>
}>
<DashboardContent />
</Suspense>
);
}

View File

@ -0,0 +1,336 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Send, Square, Loader2, File, Upload, X } from "lucide-react";
import { createClient } from "@/lib/supabase/client";
import { toast } from "sonner";
// Define API_URL
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
interface ChatInputProps {
onSubmit: (message: string) => void;
placeholder?: string;
loading?: boolean;
disabled?: boolean;
isAgentRunning?: boolean;
onStopAgent?: () => void;
autoFocus?: boolean;
value?: string;
onChange?: (value: string) => void;
onFileBrowse?: () => void;
sandboxId?: string;
}
interface UploadedFile {
name: string;
path: string;
size: number;
}
export function ChatInput({
onSubmit,
placeholder = "Type your message... (Enter to send, Shift+Enter for new line)",
loading = false,
disabled = false,
isAgentRunning = false,
onStopAgent,
autoFocus = true,
value,
onChange,
onFileBrowse,
sandboxId
}: ChatInputProps) {
const [inputValue, setInputValue] = useState(value || "");
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
const [isUploading, setIsUploading] = useState(false);
// Allow controlled or uncontrolled usage
const isControlled = value !== undefined && onChange !== undefined;
// Update local state if controlled and value changes
useEffect(() => {
if (isControlled && value !== inputValue) {
setInputValue(value);
}
}, [value, isControlled, inputValue]);
// Auto-focus on textarea when component loads
useEffect(() => {
if (autoFocus && textareaRef.current) {
textareaRef.current.focus();
}
}, [autoFocus]);
// Adjust textarea height based on content
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const adjustHeight = () => {
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 200); // Max height of 200px
textarea.style.height = `${newHeight}px`;
};
adjustHeight();
// Adjust on window resize too
window.addEventListener('resize', adjustHeight);
return () => window.removeEventListener('resize', adjustHeight);
}, [inputValue]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if ((!inputValue.trim() && uploadedFiles.length === 0) || loading || (disabled && !isAgentRunning)) return;
if (isAgentRunning && onStopAgent) {
onStopAgent();
return;
}
let message = inputValue;
// Add file information to the message if files were uploaded
if (uploadedFiles.length > 0) {
const fileInfo = uploadedFiles.map(file =>
`[Uploaded file: ${file.name} (${formatFileSize(file.size)}) at ${file.path}]`
).join('\n');
message = message ? `${message}\n\n${fileInfo}` : fileInfo;
}
onSubmit(message);
if (!isControlled) {
setInputValue("");
}
// Reset the uploaded files after sending
setUploadedFiles([]);
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
if (isControlled) {
onChange(newValue);
} else {
setInputValue(newValue);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if ((inputValue.trim() || uploadedFiles.length > 0) && !loading && (!disabled || isAgentRunning)) {
handleSubmit(e as React.FormEvent);
}
}
};
const handleFileUpload = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const processFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!sandboxId || !event.target.files || event.target.files.length === 0) return;
try {
setIsUploading(true);
const files = Array.from(event.target.files);
const newUploadedFiles: UploadedFile[] = [];
for (const file of files) {
if (file.size > 50 * 1024 * 1024) { // 50MB limit
toast.error(`File size exceeds 50MB limit: ${file.name}`);
continue;
}
// Create a FormData object
const formData = new FormData();
formData.append('file', file);
// Upload to workspace root by default
const uploadPath = `/workspace/${file.name}`;
formData.append('path', uploadPath);
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('No access token available');
}
// Upload using FormData
const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
body: formData
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
// Add to uploaded files
newUploadedFiles.push({
name: file.name,
path: uploadPath,
size: file.size
});
toast.success(`File uploaded: ${file.name}`);
}
// Update the uploaded files state
setUploadedFiles(prev => [...prev, ...newUploadedFiles]);
} catch (error) {
console.error("File upload failed:", error);
toast.error(typeof error === 'string' ? error : (error instanceof Error ? error.message : "Failed to upload file"));
} finally {
setIsUploading(false);
// Reset the input
event.target.value = '';
}
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const removeUploadedFile = (index: number) => {
setUploadedFiles(prev => prev.filter((_, i) => i !== index));
};
return (
<div className="w-full">
<form onSubmit={handleSubmit} className="relative">
{uploadedFiles.length > 0 && (
<div className="mb-2 space-y-1">
{uploadedFiles.map((file, index) => (
<div key={index} className="p-2 bg-secondary/20 rounded-md flex items-center justify-between">
<div className="flex items-center text-sm">
<File className="h-4 w-4 mr-2 text-primary" />
<span className="font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground ml-2">
({formatFileSize(file.size)})
</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => removeUploadedFile(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
<Textarea
ref={textareaRef}
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={
isAgentRunning
? "Agent is thinking..."
: placeholder
}
className="min-h-[50px] max-h-[200px] pr-20 resize-none"
disabled={loading || (disabled && !isAgentRunning)}
rows={1}
/>
<div className="absolute right-2 bottom-2 flex items-center space-x-1">
{/* Upload file button */}
<Button
type="button"
onClick={handleFileUpload}
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
disabled={loading || (disabled && !isAgentRunning) || isUploading}
aria-label="Upload files"
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
</Button>
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={processFileUpload}
multiple
/>
{/* File browser button */}
{onFileBrowse && (
<Button
type="button"
onClick={onFileBrowse}
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
disabled={loading || (disabled && !isAgentRunning)}
aria-label="Browse files"
>
<File className="h-4 w-4" />
</Button>
)}
<Button
type={isAgentRunning ? 'button' : 'submit'}
onClick={isAgentRunning ? onStopAgent : undefined}
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
disabled={((!inputValue.trim() && uploadedFiles.length === 0) && !isAgentRunning) || loading || (disabled && !isAgentRunning)}
aria-label={isAgentRunning ? 'Stop agent' : 'Send message'}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isAgentRunning ? (
<Square className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</form>
{isAgentRunning && (
<div className="mt-2 flex items-center justify-center gap-2">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<span className="inline-flex items-center">
<Loader2 className="h-3 w-3 animate-spin mr-1" />
Agent is thinking...
</span>
<span className="text-muted-foreground/60 border-l pl-1.5">
Press <kbd className="inline-flex items-center justify-center p-0.5 bg-muted border rounded text-xs"><Square className="h-2.5 w-2.5" /></kbd> to stop
</span>
</div>
</div>
)}
</div>
);
}

View File

@ -5,16 +5,18 @@ import { ReactNode, useState, useEffect, useRef, useCallback } from "react";
import {
ChevronLeft,
ChevronRight,
Home,
LayoutDashboard,
Settings,
Users,
X,
Maximize2,
GripHorizontal,
Moon, Sun
Moon, Sun,
Cpu,
Database,
MessagesSquare,
ArrowRight,
PanelLeft,
} from "lucide-react";
import { useTheme } from "next-themes";
import { getProjects, getThreads } from "@/lib/api";
import UserAccountPanel from "@/components/dashboard/user-account-panel";
@ -23,22 +25,27 @@ interface NavItemProps {
label: string;
href: string;
isCollapsed: boolean;
hideIconWhenCollapsed?: boolean;
}
const NavItem = ({ icon, label, href, isCollapsed }: NavItemProps) => {
const NavItem = ({ icon, label, href, isCollapsed, hideIconWhenCollapsed = false }: NavItemProps) => {
return (
<Link
href={href}
className={`flex items-center gap-3 p-3 rounded-full hover:bg-hover-bg dark:hover:bg-hover-bg-dark transition-all duration-200 group ${
className={`flex items-center gap-2 py-1.5 px-2 hover:bg-hover-bg dark:hover:bg-hover-bg-dark transition-all duration-200 group text-sm ${
isCollapsed ? "justify-center" : ""
}`}
>
<div className="text-icon-color dark:text-icon-color-dark">{icon}</div>
{!isCollapsed && <span className="text-foreground/90">{label}</span>}
{isCollapsed && (
{(!isCollapsed || !hideIconWhenCollapsed) && (
<div className="text-icon-color dark:text-icon-color-dark flex-shrink-0">
{icon}
</div>
)}
{!isCollapsed && <span className="text-foreground/90 truncate">{label}</span>}
{isCollapsed && !hideIconWhenCollapsed && (
<div className="absolute left-full ml-2 scale-0 group-hover:scale-100 transition-all duration-200 origin-left z-50">
<div className="bg-card-bg dark:bg-background-secondary p-2 rounded-xl shadow-custom border border-subtle dark:border-white/10">
<span className="whitespace-nowrap text-foreground">{label}</span>
<div className="bg-background-secondary dark:bg-background-secondary p-2 shadow-custom border border-subtle dark:border-white/10">
<span className="whitespace-nowrap text-foreground text-xs">{label}</span>
</div>
</div>
)}
@ -82,6 +89,43 @@ export default function DashboardLayout({
const layoutInitialized = useRef(false);
const { theme, setTheme } = useTheme();
// State for dynamic agents list
const [agents, setAgents] = useState<{name: string, href: string}[]>([]);
const [isLoadingAgents, setIsLoadingAgents] = useState(true);
// Load agents dynamically from the API
useEffect(() => {
async function loadAgents() {
try {
const projectsData = await getProjects();
const agentsList = [];
for (const project of projectsData) {
const threads = await getThreads(project.id);
if (threads && threads.length > 0) {
// For each thread in the project, create an agent entry
for (const thread of threads) {
agentsList.push({
name: `${project.name} - ${thread.thread_id.slice(0, 4)}`,
href: `/dashboard/agents/${thread.thread_id}`
});
}
}
}
// Sort by most recent (we don't have a created_at field for these mappings,
// so we'll just use the order they come in for now)
setAgents(agentsList);
} catch (err) {
console.error("Error loading agents for sidebar:", err);
} finally {
setIsLoadingAgents(false);
}
}
loadAgents();
}, []);
// Set initial showRightSidebar based on rightPanelContent prop
useEffect(() => {
@ -283,264 +327,294 @@ export default function DashboardLayout({
};
}, [isDragging, handleMouseMove]);
// Map navigation items to include icons if not provided
const navItemsWithIcons = navigation.map((item, index) => {
const defaultIcons = [
<Home key={0} size={20} />,
<LayoutDashboard key={1} size={20} />,
<Users key={2} size={20} />,
<Settings key={3} size={20} />,
];
return {
...item,
icon: item.icon || defaultIcons[index % defaultIcons.length],
};
});
// Get only the latest 20 agents for the sidebar
const recentAgents = agents.slice(0, 20);
return (
<div className="flex h-screen overflow-hidden bg-background text-foreground p-2 gap-2">
<div className="flex h-screen overflow-hidden bg-background text-foreground relative">
{/* Left Sidebar Container - always present, handles transitions */}
<div
className={`transition-all duration-300 ease-in-out ${
!leftSidebarCollapsed ? "w-64 opacity-100" : "w-0 opacity-0"
!leftSidebarCollapsed ? "w-56" : "w-12"
}`}
>
{/* Expanded Left Sidebar */}
{/* Left Sidebar */}
<div
className="w-full h-[calc(100vh-16px)] shadow-custom flex-shrink-0 flex flex-col border border-subtle dark:border-white/10 bg-card-bg dark:bg-background-secondary rounded-2xl relative overflow-hidden"
className="flex flex-col h-full bg-background-secondary dark:bg-background-secondary"
style={{ backdropFilter: "blur(8px)" }}
>
<div className="h-16 p-4 flex items-center justify-between border-b border-subtle dark:border-white/10 rounded-t-2xl">
<div className="font-bold text-xl text-card-title">
<Link href="/">Kortix / Suna</Link>
<div className="h-12 p-2 flex items-center justify-between">
<div className={`font-medium text-sm text-card-title ${leftSidebarCollapsed ? "hidden" : "block"}`}>
<Link href="/">AgentPress</Link>
</div>
<button
onClick={toggleLeftSidebar}
className="p-2 rounded-full hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/60 transition-all duration-200"
aria-label="Collapse sidebar"
className={`p-1 hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/60 transition-all duration-200 ${leftSidebarCollapsed ? "mx-auto" : ""}`}
aria-label={leftSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<ChevronLeft size={16} />
{leftSidebarCollapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
</button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{navItemsWithIcons.map((item) => (
<NavItem
key={item.name}
icon={item.icon}
label={item.name}
href={item.href}
isCollapsed={leftSidebarCollapsed}
/>
))}
<div className="flex-1 overflow-y-auto">
{/* Platform Section */}
<div className="py-1 px-2">
<div className={`text-xs font-medium text-foreground/50 mb-1 ${leftSidebarCollapsed ? "hidden" : "block"}`}>
Platform
</div>
<div className="space-y-0.5">
<NavItem
icon={<Cpu size={16} />}
label="Devices"
href="/dashboard/devices"
isCollapsed={leftSidebarCollapsed}
/>
<NavItem
icon={<Database size={16} />}
label="Data"
href="/dashboard/data"
isCollapsed={leftSidebarCollapsed}
/>
</div>
</div>
{/* Agents Section */}
<div className="py-1 px-2 mt-2">
<div className={`flex justify-between items-center mb-1 ${leftSidebarCollapsed ? "hidden" : "block"}`}>
<Link href="/dashboard/agents" className="text-xs font-medium text-foreground/50">Agents</Link>
<Link href="/dashboard" className="text-xs text-foreground/50 hover:text-foreground">
+ New
</Link>
</div>
<div className="space-y-0.5">
{isLoadingAgents ? (
// Show skeleton loaders while loading
Array.from({length: 3}).map((_, index) => (
<div key={index} className="flex items-center gap-2 py-1.5 px-2">
{!leftSidebarCollapsed && (
<div className="w-4 h-4 bg-foreground/10 rounded-md animate-pulse"></div>
)}
{!leftSidebarCollapsed && (
<div className="h-3 bg-foreground/10 rounded w-3/4 animate-pulse"></div>
)}
</div>
))
) : recentAgents.length > 0 ? (
// Show only the latest 20 agents
<>
{recentAgents.map((agent, index) => (
<NavItem
key={index}
icon={<MessagesSquare size={16} />}
label={agent.name}
href={agent.href}
isCollapsed={leftSidebarCollapsed}
hideIconWhenCollapsed={true}
/>
))}
{/* "See all agents" link */}
{agents.length > 20 && (
<Link
href="/dashboard/agents"
className={`flex items-center gap-1 py-1.5 px-2 text-xs text-foreground/60 hover:text-foreground hover:bg-hover-bg dark:hover:bg-hover-bg-dark transition-all ${
leftSidebarCollapsed ? "justify-center" : ""
}`}
>
{!leftSidebarCollapsed && <span>See all agents</span>}
<ArrowRight size={12} />
</Link>
)}
</>
) : (
// Show message when no agents
<div className={`text-xs text-foreground/50 p-2 ${leftSidebarCollapsed ? "hidden" : "block"}`}>
No agents yet
</div>
)}
</div>
</div>
</div>
{/* User Account Panel at the bottom */}
<UserAccountPanel
accountId={accountId}
userName={userName}
userEmail={userEmail}
isCollapsed={leftSidebarCollapsed}
/>
<div className="mt-auto border-t border-subtle dark:border-white/10">
<UserAccountPanel
accountId={accountId}
userName={userName}
userEmail={userEmail}
isCollapsed={leftSidebarCollapsed}
/>
</div>
</div>
</div>
{/* Collapsed Left Sidebar Button - only visible when collapsed */}
<div
className={`transition-all duration-300 ease-in-out ${
leftSidebarCollapsed ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
style={{
position: "absolute",
top: "8px",
left: "8px",
zIndex: 10,
}}
>
<div
className="w-12 h-12 shadow-custom flex-shrink-0 border border-subtle dark:border-white/10 bg-card-bg dark:bg-background-secondary rounded-2xl relative overflow-hidden"
style={{ backdropFilter: "blur(8px)" }}
{/* Layout container for main content and right panel */}
<div className="flex flex-1 relative">
{/* Main Content */}
<div
className={`flex flex-col h-screen overflow-hidden bg-background dark:bg-background transition-all duration-300 ease-in-out rounded-l-xl shadow-custom border-l border-subtle dark:border-white/10 ${
showRightSidebar && !rightSidebarCollapsed
? "w-[calc(100%-40%)]"
: "w-full"
}`}
style={{
backdropFilter: "blur(8px)",
zIndex: 10,
marginLeft: leftSidebarCollapsed ? "0" : "0"
}}
>
<button
onClick={toggleLeftSidebar}
className="w-full h-full flex items-center hover:bg-hover-bg dark:hover:bg-hover-bg-dark justify-center text-foreground/60 transition-all duration-200"
aria-label="Expand sidebar"
>
<ChevronRight size={16} />
</button>
</div>
</div>
{/* User controls for collapsed state - positioned at bottom left */}
<div
className={`transition-all duration-300 ease-in-out ${
leftSidebarCollapsed ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
style={{
position: "absolute",
bottom: "8px",
left: "8px",
zIndex: 10,
}}
>
<UserAccountPanel
accountId={accountId}
userName={userName}
userEmail={userEmail}
isCollapsed={true}
/>
</div>
{/* Main Content - Now expands to fill space */}
<div className="flex flex-col flex-1 h-[calc(100vh-16px)] rounded-2xl overflow-hidden bg-transparent transition-all duration-300 ease-in-out">
<header className="h-16 px-6 flex items-center justify-end">
<div className="flex items-center gap-4">
{!showRightSidebar && rightPanelContent && (
<button
onClick={toggleRightSidebarVisibility}
className="px-4 py-2 rounded-full hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/80 hover:text-foreground transition-all duration-200 text-sm font-medium border border-subtle dark:border-white/10"
>
{rightPanelTitle}
</button>
)}
<div className="relative">
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="relative px-4 py-2 rounded-full hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/80 hover:text-foreground transition-all duration-200 text-sm font-medium border border-subtle dark:border-white/10"
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
<span className="sr-only">Toggle theme</span>
</button>
</div>
</div>
</header>
<main className="flex-1 overflow-y-auto bg-transparent main-content-area relative">
<div
className={`mx-auto p-6 transition-all duration-300 ${
// Increase container width when sidebars are collapsed
leftSidebarCollapsed &&
(rightSidebarCollapsed || !showRightSidebar)
? "container max-w-[95%]"
: "container"
}`}
>
{children}
</div>
{/* Floating draggable right panel - only show when right sidebar is collapsed and visible */}
{showRightSidebar && rightSidebarCollapsed && rightPanelContent && (
<div
ref={floatingPanelRef}
className={`absolute bg-card-bg dark:bg-background-secondary border border-subtle dark:border-white/10 rounded-xl shadow-custom flex flex-col w-80 overflow-hidden ${
isDragging ? "cursor-grabbing" : ""
}`}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
backdropFilter: "blur(8px)",
zIndex: 50,
}}
onMouseDown={handleMouseDown}
>
<div className="p-3 flex items-center justify-between border-b border-subtle dark:border-white/10 drag-handle cursor-grab">
<div className="flex items-center gap-2">
<GripHorizontal
size={14}
className="text-icon-color dark:text-icon-color-dark"
/>
<h3 className="font-medium text-foreground select-none">
{rightPanelTitle}
</h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={toggleRightSidebar}
className="p-1.5 rounded-full hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/60 transition-all duration-200"
aria-label="Expand panel"
>
<Maximize2 size={14} />
</button>
<button
onClick={toggleRightSidebarVisibility}
className="p-1.5 rounded-full hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/60 transition-all duration-200"
aria-label="Close panel"
>
<X size={14} />
</button>
</div>
</div>
<div className="p-4 max-h-60 overflow-y-auto">
<div className="flex flex-col items-center justify-center py-6 text-foreground/40">
<p className="mb-2 select-none">Content minimized</p>
<button
onClick={toggleRightSidebar}
className="px-3 py-1.5 rounded-full bg-button-hover dark:bg-button-hover-dark text-primary text-sm font-medium"
>
Expand
</button>
</div>
</div>
<div
className="absolute inset-x-0 bottom-0 h-[40%] pointer-events-none bg-gradient-overlay"
style={{
opacity: 0.4,
mixBlendMode: "multiply",
}}
/>
</div>
)}
</main>
</div>
{/* Right Sidebar - only show when visible */}
{showRightSidebar && rightPanelContent && (
<div
className={`h-[calc(100vh-16px)] border border-subtle dark:border-white/10 bg-card-bg dark:bg-background-secondary transition-all duration-300 ease-in-out rounded-2xl ${
rightSidebarCollapsed
? "w-0 opacity-0 p-0 m-0 border-0"
: "w-[45%] opacity-100 flex-shrink-0"
} shadow-custom relative overflow-hidden`}
style={{ backdropFilter: "blur(8px)" }}
>
<div className="h-16 p-4 flex items-center justify-between border-b border-subtle dark:border-white/10 rounded-t-2xl">
<h2 className="font-semibold text-card-title">{rightPanelTitle}</h2>
<header className="h-12 px-4 flex items-center justify-end">
<div className="flex items-center gap-2">
<button
onClick={toggleRightSidebar}
className="p-2 rounded-full hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/60 transition-all duration-200"
aria-label="Collapse sidebar"
>
<ChevronRight size={16} />
</button>
<button
onClick={toggleRightSidebarVisibility}
className="p-2 rounded-full hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/60 transition-all duration-200"
aria-label="Close sidebar"
>
<X size={16} />
</button>
{!showRightSidebar && rightPanelContent && (
<button
onClick={toggleRightSidebarVisibility}
className="px-3 py-1.5 rounded-full hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/80 hover:text-foreground transition-all duration-200 text-xs border border-subtle dark:border-white/10"
>
{rightPanelTitle}
</button>
)}
<div className="relative">
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="relative px-3 py-1.5 rounded-full hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/80 hover:text-foreground transition-all duration-200 text-xs border border-subtle dark:border-white/10"
>
<Sun className="h-3.5 w-3.5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-3.5 w-3.5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
<span className="sr-only">Toggle theme</span>
</button>
</div>
</div>
</div>
</header>
<div className="p-4 overflow-y-auto h-[calc(100vh-80px)]">
{rightPanelContent || (
<div className="flex flex-col items-center justify-center h-full text-foreground/40">
<p>No content selected</p>
<main className="flex-1 overflow-y-auto bg-transparent main-content-area relative">
<div
className={`mx-auto p-4 transition-all duration-300 ${
// Increase container width when sidebars are collapsed
leftSidebarCollapsed &&
(rightSidebarCollapsed || !showRightSidebar)
? "container max-w-[95%]"
: "container"
}`}
>
{children}
</div>
{/* Floating draggable right panel - only show when right sidebar is collapsed and visible */}
{showRightSidebar && rightSidebarCollapsed && rightPanelContent && (
<div
ref={floatingPanelRef}
className={`absolute bg-card-bg dark:bg-background-secondary border border-subtle dark:border-white/10 rounded-lg shadow-custom flex flex-col w-72 overflow-hidden ${
isDragging ? "cursor-grabbing" : ""
}`}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
backdropFilter: "blur(8px)",
zIndex: 50,
}}
onMouseDown={handleMouseDown}
>
<div className="p-2 flex items-center justify-between drag-handle cursor-grab">
<div className="flex items-center gap-1.5">
<GripHorizontal
size={12}
className="text-icon-color dark:text-icon-color-dark"
/>
<h3 className="font-medium text-foreground text-xs select-none">
{rightPanelTitle}
</h3>
</div>
<div className="flex items-center gap-1">
<button
onClick={toggleRightSidebar}
className="p-1 rounded-full hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/60 transition-all duration-200"
aria-label="Expand panel"
>
<Maximize2 size={12} />
</button>
<button
onClick={toggleRightSidebarVisibility}
className="p-1 rounded-full hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/60 transition-all duration-200"
aria-label="Close panel"
>
<X size={12} />
</button>
</div>
</div>
<div className="p-3 max-h-60 overflow-y-auto">
<div className="flex flex-col items-center justify-center py-4 text-foreground/40">
<p className="mb-2 select-none text-xs">Content minimized</p>
<button
onClick={toggleRightSidebar}
className="px-2 py-1 rounded-md bg-button-hover dark:bg-button-hover-dark text-primary text-xs"
>
Expand
</button>
</div>
</div>
<div
className="absolute inset-x-0 bottom-0 h-[40%] pointer-events-none bg-gradient-overlay"
style={{
opacity: 0.4,
mixBlendMode: "multiply",
}}
/>
</div>
)}
</div>
<div
className="absolute inset-x-0 bottom-0 h-[40%] pointer-events-none bg-gradient-overlay"
style={{
opacity: 0.4,
mixBlendMode: "multiply",
}}
/>
</main>
</div>
)}
{/* Right Sidebar - only show when visible */}
{showRightSidebar && rightPanelContent && (
<div
className={`h-screen border-l border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary transition-all duration-300 ease-in-out rounded-l-xl shadow-custom ${
rightSidebarCollapsed
? "w-0 opacity-0 p-0 m-0 border-0"
: "w-[40%] opacity-100 flex-shrink-0"
} overflow-hidden`}
style={{
backdropFilter: "blur(8px)",
zIndex: 20
}}
>
<div className="h-12 p-2 flex items-center justify-between rounded-t-xl">
<h2 className="font-medium text-sm text-card-title">{rightPanelTitle}</h2>
<div className="flex items-center gap-1">
<button
onClick={toggleRightSidebar}
className="p-1 hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/60 transition-all duration-200"
aria-label="Collapse sidebar"
>
<ChevronRight size={14} />
</button>
<button
onClick={toggleRightSidebarVisibility}
className="p-1 hover:bg-hover-bg dark:hover:bg-hover-bg-dark text-foreground/60 transition-all duration-200"
aria-label="Close sidebar"
>
<X size={14} />
</button>
</div>
</div>
<div className="p-3 overflow-y-auto h-[calc(100vh-48px)]">
{rightPanelContent || (
<div className="flex flex-col items-center justify-center h-full text-foreground/40">
<p className="text-sm">No content selected</p>
</div>
)}
</div>
<div
className="absolute inset-x-0 bottom-0 h-[40%] pointer-events-none bg-gradient-overlay"
style={{
opacity: 0.4,
mixBlendMode: "multiply",
}}
/>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -0,0 +1,835 @@
import { createClient } from '@/lib/supabase/client';
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
// Simple cache implementation
const apiCache = {
projects: new Map(),
threads: new Map(),
threadMessages: new Map(),
agentRuns: new Map(),
getProject: (projectId: string) => apiCache.projects.get(projectId),
setProject: (projectId: string, data: any) => apiCache.projects.set(projectId, data),
getProjects: () => apiCache.projects.get('all'),
setProjects: (data: any) => apiCache.projects.set('all', data),
getThreads: (projectId: string) => apiCache.threads.get(projectId || 'all'),
setThreads: (projectId: string, data: any) => apiCache.threads.set(projectId || 'all', data),
getThreadMessages: (threadId: string) => apiCache.threadMessages.get(threadId),
setThreadMessages: (threadId: string, data: any) => apiCache.threadMessages.set(threadId, data),
getAgentRuns: (threadId: string) => apiCache.agentRuns.get(threadId),
setAgentRuns: (threadId: string, data: any) => apiCache.agentRuns.set(threadId, data),
// Helper to clear parts of the cache when data changes
invalidateThreadMessages: (threadId: string) => apiCache.threadMessages.delete(threadId),
invalidateAgentRuns: (threadId: string) => apiCache.agentRuns.delete(threadId),
};
// Add a fetch queue system to prevent multiple simultaneous requests
const fetchQueue = {
agentRuns: new Map<string, Promise<any>>(),
threads: new Map<string, Promise<any>>(),
messages: new Map<string, Promise<any>>(),
projects: new Map<string, Promise<any>>(),
getQueuedAgentRuns: (threadId: string) => fetchQueue.agentRuns.get(threadId),
setQueuedAgentRuns: (threadId: string, promise: Promise<any>) => {
fetchQueue.agentRuns.set(threadId, promise);
// Auto-clean the queue after the promise resolves
promise.finally(() => {
fetchQueue.agentRuns.delete(threadId);
});
return promise;
},
getQueuedThreads: (projectId: string) => fetchQueue.threads.get(projectId || 'all'),
setQueuedThreads: (projectId: string, promise: Promise<any>) => {
fetchQueue.threads.set(projectId || 'all', promise);
promise.finally(() => {
fetchQueue.threads.delete(projectId || 'all');
});
return promise;
},
getQueuedMessages: (threadId: string) => fetchQueue.messages.get(threadId),
setQueuedMessages: (threadId: string, promise: Promise<any>) => {
fetchQueue.messages.set(threadId, promise);
promise.finally(() => {
fetchQueue.messages.delete(threadId);
});
return promise;
},
getQueuedProjects: () => fetchQueue.projects.get('all'),
setQueuedProjects: (promise: Promise<any>) => {
fetchQueue.projects.set('all', promise);
promise.finally(() => {
fetchQueue.projects.delete('all');
});
return promise;
}
};
export type Project = {
id: string;
name: string;
description: string;
account_id: string;
created_at: string;
sandbox_id?: string;
sandbox_pass?: string;
}
export type Thread = {
thread_id: string;
account_id: string | null;
project_id?: string | null;
created_at: string;
updated_at: string;
}
export type Message = {
role: string;
content: string;
}
export type AgentRun = {
id: string;
thread_id: string;
status: 'running' | 'completed' | 'stopped' | 'error';
started_at: string;
completed_at: string | null;
responses: Message[];
error: string | null;
}
export type ToolCall = {
name: string;
arguments: Record<string, unknown>;
}
// Project APIs
export const getProjects = async (): Promise<Project[]> => {
// Check if we already have a pending request
const pendingRequest = fetchQueue.getQueuedProjects();
if (pendingRequest) {
return pendingRequest;
}
// Check cache first
const cached = apiCache.getProjects();
if (cached) {
return cached;
}
// Create and queue the promise
const fetchPromise = (async () => {
try {
const supabase = createClient();
const { data, error } = await supabase
.from('projects')
.select('*');
if (error) {
// Handle permission errors specifically
if (error.code === '42501' && error.message.includes('has_role_on_account')) {
console.error('Permission error: User does not have proper account access');
return []; // Return empty array instead of throwing
}
throw error;
}
// Cache the result
apiCache.setProjects(data || []);
return data || [];
} catch (err) {
console.error('Error fetching projects:', err);
// Return empty array for permission errors to avoid crashing the UI
return [];
}
})();
// Add to queue and return
return fetchQueue.setQueuedProjects(fetchPromise);
};
export const getProject = async (projectId: string): Promise<Project> => {
// Check cache first
const cached = apiCache.getProject(projectId);
if (cached) {
return cached;
}
const supabase = createClient();
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('project_id', projectId)
.single();
if (error) throw error;
// Cache the result
apiCache.setProject(projectId, data);
return data;
};
export const createProject = async (
projectData: { name: string; description: string },
accountId?: string
): Promise<Project> => {
const supabase = createClient();
// If accountId is not provided, we'll need to get the user's ID
if (!accountId) {
const { data: userData, error: userError } = await supabase.auth.getUser();
if (userError) throw userError;
if (!userData.user) throw new Error('You must be logged in to create a project');
// In Basejump, the personal account ID is the same as the user ID
accountId = userData.user.id;
}
const { data, error } = await supabase
.from('projects')
.insert({
name: projectData.name,
description: projectData.description || null,
account_id: accountId
})
.select()
.single();
if (error) throw error;
// Map the database response to our Project type
return {
id: data.project_id,
name: data.name,
description: data.description || '',
account_id: data.account_id,
created_at: data.created_at
};
};
export const updateProject = async (projectId: string, data: Partial<Project>): Promise<Project> => {
const supabase = createClient();
const { data: updatedData, error } = await supabase
.from('projects')
.update(data)
.eq('project_id', projectId)
.select()
.single();
if (error) throw error;
return updatedData;
};
export const deleteProject = async (projectId: string): Promise<void> => {
const supabase = createClient();
const { error } = await supabase
.from('projects')
.delete()
.eq('project_id', projectId);
if (error) throw error;
};
// Thread APIs
export const getThreads = async (projectId?: string): Promise<Thread[]> => {
// Check if we already have a pending request
const pendingRequest = fetchQueue.getQueuedThreads(projectId || 'all');
if (pendingRequest) {
return pendingRequest;
}
// Check cache first
const cached = apiCache.getThreads(projectId || 'all');
if (cached) {
return cached;
}
// Create and queue the promise
const fetchPromise = (async () => {
const supabase = createClient();
let query = supabase.from('threads').select('*');
if (projectId) {
query = query.eq('project_id', projectId);
}
const { data, error } = await query;
if (error) throw error;
// Cache the result
apiCache.setThreads(projectId || 'all', data || []);
return data || [];
})();
// Add to queue and return
return fetchQueue.setQueuedThreads(projectId || 'all', fetchPromise);
};
export const getThread = async (threadId: string): Promise<Thread> => {
const supabase = createClient();
const { data, error } = await supabase
.from('threads')
.select('*')
.eq('thread_id', threadId)
.single();
if (error) throw error;
return data;
};
export const createThread = async (projectId: string): Promise<Thread> => {
const supabase = createClient();
// If user is not logged in, redirect to login
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('You must be logged in to create a thread');
}
const { data, error } = await supabase
.from('threads')
.insert({
project_id: projectId,
account_id: user.id, // Use the current user's ID as the account ID
})
.select()
.single();
if (error) throw error;
return data;
};
export const addUserMessage = async (threadId: string, content: string): Promise<void> => {
const supabase = createClient();
// Format the message in the format the LLM expects - keep it simple with only required fields
const message = {
role: 'user',
content: content
};
// Insert the message into the messages table
const { error } = await supabase
.from('messages')
.insert({
thread_id: threadId,
type: 'user',
is_llm_message: true,
content: JSON.stringify(message)
});
if (error) {
console.error('Error adding user message:', error);
throw new Error(`Error adding message: ${error.message}`);
}
// Invalidate the cache for this thread's messages
apiCache.invalidateThreadMessages(threadId);
};
export const getMessages = async (threadId: string, hideToolMsgs: boolean = false): Promise<Message[]> => {
// Check if we already have a pending request
const pendingRequest = fetchQueue.getQueuedMessages(threadId);
if (pendingRequest) {
// Apply filter if needed when promise resolves
if (hideToolMsgs) {
return pendingRequest.then(messages =>
messages.filter((msg: Message) => msg.role !== 'tool')
);
}
return pendingRequest;
}
// Check cache first
const cached = apiCache.getThreadMessages(threadId);
if (cached) {
// Apply filter if needed
return hideToolMsgs ? cached.filter((msg: Message) => msg.role !== 'tool') : cached;
}
// Create and queue the promise
const fetchPromise = (async () => {
const supabase = createClient();
// Query all messages for the thread instead of using RPC
const { data, error } = await supabase
.from('messages')
.select('*')
.eq('thread_id', threadId)
.order('created_at', { ascending: true });
if (error) {
console.error('Error fetching messages:', error);
throw new Error(`Error getting messages: ${error.message}`);
}
// Process the messages to the expected format
const messages = (data || []).map(msg => {
try {
// Parse the content from JSONB
const content = typeof msg.content === 'string'
? JSON.parse(msg.content)
: msg.content;
// Return in the format the app expects
return content;
} catch (e) {
console.error('Error parsing message content:', e, msg);
// Fallback for malformed messages
return {
role: msg.is_llm_message ? 'assistant' : 'user',
content: 'Error: Could not parse message content'
};
}
});
// Cache the result
apiCache.setThreadMessages(threadId, messages);
return messages;
})();
// Add to queue
const queuedPromise = fetchQueue.setQueuedMessages(threadId, fetchPromise);
// Apply filter if needed when promise resolves
if (hideToolMsgs) {
return queuedPromise.then(messages =>
messages.filter((msg: Message) => msg.role !== 'tool')
);
}
return queuedPromise;
};
// Agent APIs
export const startAgent = async (threadId: string): Promise<{ agent_run_id: string }> => {
try {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('No access token available');
}
// Check if backend URL is configured
if (!API_URL) {
throw new Error('Backend URL is not configured. Set NEXT_PUBLIC_BACKEND_URL in your environment.');
}
console.log(`[API] Starting agent for thread ${threadId} using ${API_URL}/thread/${threadId}/agent/start`);
const response = await fetch(`${API_URL}/thread/${threadId}/agent/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'No error details available');
console.error(`[API] Error starting agent: ${response.status} ${response.statusText}`, errorText);
throw new Error(`Error starting agent: ${response.statusText} (${response.status})`);
}
// Invalidate relevant caches
apiCache.invalidateAgentRuns(threadId);
apiCache.invalidateThreadMessages(threadId);
return response.json();
} catch (error) {
console.error('[API] Failed to start agent:', error);
// Provide clearer error message for network errors
if (error instanceof TypeError && error.message.includes('Failed to fetch')) {
throw new Error(`Cannot connect to backend server. Please check your internet connection and make sure the backend is running.`);
}
throw error;
}
};
export const stopAgent = async (agentRunId: string): Promise<void> => {
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-run/${agentRunId}/stop`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
throw new Error(`Error stopping agent: ${response.statusText}`);
}
};
export const getAgentStatus = async (agentRunId: string): Promise<AgentRun> => {
console.log(`[API] ⚠️ Requesting agent status for ${agentRunId}`);
try {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
console.error('[API] ❌ No access token available for getAgentStatus');
throw new Error('No access token available');
}
const url = `${API_URL}/agent-run/${agentRunId}`;
console.log(`[API] 🔍 Fetching from: ${url}`);
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'No error details available');
console.error(`[API] ❌ Error getting agent status: ${response.status} ${response.statusText}`, errorText);
throw new Error(`Error getting agent status: ${response.statusText} (${response.status})`);
}
const data = await response.json();
console.log(`[API] ✅ Successfully got agent status:`, data);
return data;
} catch (error) {
console.error('[API] ❌ Failed to get agent status:', error);
throw error;
}
};
export const getAgentRuns = async (threadId: string): Promise<AgentRun[]> => {
// Check if we already have a pending request for this thread ID
const pendingRequest = fetchQueue.getQueuedAgentRuns(threadId);
if (pendingRequest) {
return pendingRequest;
}
// Check cache first
const cached = apiCache.getAgentRuns(threadId);
if (cached) {
return cached;
}
// Create and queue the promise to prevent duplicate requests
const fetchPromise = (async () => {
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}/thread/${threadId}/agent-runs`, {
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
throw new Error(`Error getting agent runs: ${response.statusText}`);
}
const data = await response.json();
const agentRuns = data.agent_runs || [];
// Cache the result
apiCache.setAgentRuns(threadId, agentRuns);
return agentRuns;
})();
// Add to queue and return
return fetchQueue.setQueuedAgentRuns(threadId, fetchPromise);
};
export const streamAgent = (agentRunId: string, callbacks: {
onMessage: (content: string) => void;
onError: (error: Error | string) => void;
onClose: () => void;
}): () => void => {
let eventSourceInstance: EventSource | null = null;
let isClosing = false;
console.log(`[STREAM] Setting up stream for agent run ${agentRunId}`);
const setupStream = async () => {
try {
if (isClosing) {
console.log(`[STREAM] Already closing, not setting up stream for ${agentRunId}`);
return;
}
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
console.error('[STREAM] No auth token available');
callbacks.onError(new Error('Authentication required'));
callbacks.onClose();
return;
}
const url = new URL(`${API_URL}/agent-run/${agentRunId}/stream`);
url.searchParams.append('token', session.access_token);
console.log(`[STREAM] Creating EventSource for ${agentRunId}`);
eventSourceInstance = new EventSource(url.toString());
eventSourceInstance.onopen = () => {
console.log(`[STREAM] Connection opened for ${agentRunId}`);
};
eventSourceInstance.onmessage = (event) => {
try {
const rawData = event.data;
if (rawData.includes('"type":"ping"')) return;
// Log raw data for debugging
console.log(`[STREAM] Received data: ${rawData.substring(0, 100)}${rawData.length > 100 ? '...' : ''}`);
// Skip empty messages
if (!rawData || rawData.trim() === '') {
console.debug('[STREAM] Received empty message, skipping');
return;
}
// Check if this is a status completion message
if (rawData.includes('"type":"status"') && rawData.includes('"status":"completed"')) {
console.log(`[STREAM] ⚠️ Detected completion status message: ${rawData}`);
try {
// Explicitly call onMessage before closing the stream to ensure the message is processed
callbacks.onMessage(rawData);
// Explicitly close the EventSource connection when we receive a completion message
if (eventSourceInstance && !isClosing) {
console.log(`[STREAM] ⚠️ Closing EventSource due to completion message for ${agentRunId}`);
isClosing = true;
eventSourceInstance.close();
eventSourceInstance = null;
// Explicitly call onClose here to ensure the client knows the stream is closed
setTimeout(() => {
console.log(`[STREAM] 🚨 Explicitly calling onClose after completion for ${agentRunId}`);
callbacks.onClose();
}, 0);
}
// Exit early to prevent duplicate message processing
return;
} catch (closeError) {
console.error(`[STREAM] ❌ Error while closing stream on completion: ${closeError}`);
// Continue with normal processing if there's an error during closure
}
}
// Pass the raw data directly to onMessage for handling in the component
callbacks.onMessage(rawData);
} catch (error) {
console.error(`[STREAM] Error handling message:`, error);
callbacks.onError(error instanceof Error ? error : String(error));
}
};
eventSourceInstance.onerror = (event) => {
// Add detailed event logging
console.log(`[STREAM] 🔍 EventSource onerror triggered for ${agentRunId}`, event);
// EventSource errors are often just connection closures
// For clean closures (manual or completed), we don't need to log an error
if (isClosing) {
console.log(`[STREAM] EventSource closed as expected for ${agentRunId}`);
return;
}
// Only log as error for unexpected closures
console.log(`[STREAM] EventSource connection closed for ${agentRunId}`);
if (!isClosing) {
console.log(`[STREAM] Handling connection close for ${agentRunId}`);
// Close the connection
if (eventSourceInstance) {
eventSourceInstance.close();
eventSourceInstance = null;
}
// Then notify error (once)
isClosing = true;
callbacks.onClose();
}
};
} catch (error) {
console.error(`[STREAM] Error setting up stream:`, error);
if (!isClosing) {
isClosing = true;
callbacks.onError(error instanceof Error ? error : String(error));
callbacks.onClose();
}
}
};
// Set up the stream once
setupStream();
// Return cleanup function
return () => {
console.log(`[STREAM] Manual cleanup called for ${agentRunId}`);
if (isClosing) {
console.log(`[STREAM] Already closing, ignoring duplicate cleanup for ${agentRunId}`);
return;
}
isClosing = true;
if (eventSourceInstance) {
console.log(`[STREAM] Manually closing EventSource for ${agentRunId}`);
eventSourceInstance.close();
eventSourceInstance = null;
}
};
};
// Sandbox API Functions
export const createSandboxFile = async (sandboxId: string, filePath: string, content: string): Promise<void> => {
try {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('No access token available');
}
// Determine if content is likely binary (contains non-printable characters)
const isProbablyBinary = /[\x00-\x08\x0E-\x1F\x80-\xFF]/.test(content) ||
content.startsWith('data:') ||
/^[A-Za-z0-9+/]*={0,2}$/.test(content);
const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
body: JSON.stringify({
path: filePath,
content: content,
is_base64: isProbablyBinary
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'No error details available');
console.error(`Error creating sandbox file: ${response.status} ${response.statusText}`, errorText);
throw new Error(`Error creating sandbox file: ${response.statusText} (${response.status})`);
}
return response.json();
} catch (error) {
console.error('Failed to create sandbox file:', error);
throw error;
}
};
export interface FileInfo {
name: string;
path: string;
is_dir: boolean;
size: number;
mod_time: string;
permissions?: string;
}
export const listSandboxFiles = async (sandboxId: string, path: string): Promise<FileInfo[]> => {
try {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('No access token available');
}
const url = new URL(`${API_URL}/sandboxes/${sandboxId}/files`);
url.searchParams.append('path', path);
const response = await fetch(url.toString(), {
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'No error details available');
console.error(`Error listing sandbox files: ${response.status} ${response.statusText}`, errorText);
throw new Error(`Error listing sandbox files: ${response.statusText} (${response.status})`);
}
const data = await response.json();
return data.files || [];
} catch (error) {
console.error('Failed to list sandbox files:', error);
throw error;
}
};
export const getSandboxFileContent = async (sandboxId: string, path: string): Promise<string | Blob> => {
try {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('No access token available');
}
const url = new URL(`${API_URL}/sandboxes/${sandboxId}/files/content`);
url.searchParams.append('path', path);
const response = await fetch(url.toString(), {
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'No error details available');
console.error(`Error getting sandbox file content: ${response.status} ${response.statusText}`, errorText);
throw new Error(`Error getting sandbox file content: ${response.statusText} (${response.status})`);
}
// Check if it's a text file or binary file based on content-type
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('text') || contentType?.includes('application/json')) {
return await response.text();
} else {
return await response.blob();
}
} catch (error) {
console.error('Failed to get sandbox file content:', error);
throw error;
}
};