mirror of https://github.com/kortix-ai/suna.git
clean
This commit is contained in:
parent
4e5ad7ab64
commit
a6d2e5b18e
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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;
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue