Spaces:
Running
Running
import { createClient } from '@/lib/supabase/client'; | |
// Get backend URL from environment variables | |
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; | |
// Set to keep track of agent runs that are known to be non-running | |
const nonRunningAgentRuns = new Set<string>(); | |
// Map to keep track of active EventSource streams | |
const activeStreams = new Map<string, EventSource>(); | |
// Custom error for billing issues | |
export class BillingError extends Error { | |
status: number; | |
detail: { message: string; [key: string]: any }; // Allow other properties in detail | |
constructor(status: number, detail: { message: string; [key: string]: any }, message?: string) { | |
super(message || detail.message || `Billing Error: ${status}`); | |
this.name = 'BillingError'; | |
this.status = status; | |
this.detail = detail; | |
// Set the prototype explicitly. | |
Object.setPrototypeOf(this, BillingError.prototype); | |
} | |
} | |
// Type Definitions (moved from potential separate file for clarity) | |
export type Project = { | |
id: string; | |
name: string; | |
description: string; | |
account_id: string; | |
created_at: string; | |
updated_at?: string; | |
sandbox: { | |
vnc_preview?: string; | |
sandbox_url?: string; | |
id?: string; | |
pass?: string; | |
}; | |
is_public?: boolean; // Flag to indicate if the project is public | |
[key: string]: any; // Allow additional properties to handle database fields | |
} | |
export type Thread = { | |
thread_id: string; | |
account_id: string | null; | |
project_id?: string | null; | |
is_public?: boolean; | |
created_at: string; | |
updated_at: string; | |
[key: string]: any; // Allow additional properties to handle database fields | |
} | |
export type Message = { | |
role: string; | |
content: string; | |
type: 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>; | |
} | |
export interface InitiateAgentResponse { | |
thread_id: string; | |
agent_run_id: string; | |
} | |
export interface HealthCheckResponse { | |
status: string; | |
timestamp: string; | |
instance_id: string; | |
} | |
export interface FileInfo { | |
name: string; | |
path: string; | |
is_dir: boolean; | |
size: number; | |
mod_time: string; | |
permissions?: string; | |
} | |
// Project APIs | |
export const getProjects = async (): Promise<Project[]> => { | |
try { | |
const supabase = createClient(); | |
// Get the current user's ID to filter projects | |
const { data: userData, error: userError } = await supabase.auth.getUser(); | |
if (userError) { | |
console.error('Error getting current user:', userError); | |
return []; | |
} | |
// If no user is logged in, return an empty array | |
if (!userData.user) { | |
console.log('[API] No user logged in, returning empty projects array'); | |
return []; | |
} | |
// Query only projects where account_id matches the current user's ID | |
const { data, error } = await supabase | |
.from('projects') | |
.select('*') | |
.eq('account_id', userData.user.id); | |
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; | |
} | |
console.log('[API] Raw projects from DB:', data?.length, data); | |
// Map database fields to our Project type | |
const mappedProjects: Project[] = (data || []).map(project => ({ | |
id: project.project_id, | |
name: project.name || '', | |
description: project.description || '', | |
account_id: project.account_id, | |
created_at: project.created_at, | |
updated_at: project.updated_at, | |
sandbox: project.sandbox || { id: "", pass: "", vnc_preview: "", sandbox_url: "" } | |
})); | |
console.log('[API] Mapped projects for frontend:', mappedProjects.length); | |
return mappedProjects; | |
} catch (err) { | |
console.error('Error fetching projects:', err); | |
// Return empty array for permission errors to avoid crashing the UI | |
return []; | |
} | |
}; | |
export const getProject = async (projectId: string): Promise<Project> => { | |
const supabase = createClient(); | |
try { | |
const { data, error } = await supabase | |
.from('projects') | |
.select('*') | |
.eq('project_id', projectId) | |
.single(); | |
if (error) { | |
// Handle the specific "no rows returned" error from Supabase | |
if (error.code === 'PGRST116') { | |
throw new Error(`Project not found or not accessible: ${projectId}`); | |
} | |
throw error; | |
} | |
console.log('Raw project data from database:', data); | |
// If project has a sandbox, ensure it's started | |
if (data.sandbox?.id) { | |
// Fire off sandbox activation without blocking | |
const ensureSandboxActive = async () => { | |
try { | |
const { data: { session } } = await supabase.auth.getSession(); | |
// For public projects, we don't need authentication | |
const headers: Record<string, string> = { | |
'Content-Type': 'application/json' | |
}; | |
if (session?.access_token) { | |
headers['Authorization'] = `Bearer ${session.access_token}`; | |
} | |
console.log(`Ensuring sandbox is active for project ${projectId}...`); | |
const response = await fetch(`${API_URL}/project/${projectId}/sandbox/ensure-active`, { | |
method: 'POST', | |
headers, | |
}); | |
if (!response.ok) { | |
const errorText = await response.text().catch(() => 'No error details available'); | |
console.warn(`Failed to ensure sandbox is active: ${response.status} ${response.statusText}`, errorText); | |
} else { | |
console.log('Sandbox activation successful'); | |
} | |
} catch (sandboxError) { | |
console.warn('Failed to ensure sandbox is active:', sandboxError); | |
} | |
}; | |
// Start the sandbox activation without awaiting | |
ensureSandboxActive(); | |
} | |
// Map database fields to our Project type | |
const mappedProject: Project = { | |
id: data.project_id, | |
name: data.name || '', | |
description: data.description || '', | |
account_id: data.account_id, | |
created_at: data.created_at, | |
sandbox: data.sandbox || { id: "", pass: "", vnc_preview: "", sandbox_url: "" } | |
}; | |
console.log('Mapped project data for frontend:', mappedProject); | |
return mappedProject; | |
} catch (error) { | |
console.error(`Error fetching project ${projectId}:`, error); | |
throw error; | |
} | |
}; | |
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, | |
sandbox: { id: "", pass: "", vnc_preview: "" } | |
}; | |
}; | |
export const updateProject = async (projectId: string, data: Partial<Project>): Promise<Project> => { | |
const supabase = createClient(); | |
console.log('Updating project with ID:', projectId); | |
console.log('Update data:', data); | |
// Sanity check to avoid update errors | |
if (!projectId || projectId === '') { | |
console.error('Attempted to update project with invalid ID:', projectId); | |
throw new Error('Cannot update project: Invalid project ID'); | |
} | |
const { data: updatedData, error } = await supabase | |
.from('projects') | |
.update(data) | |
.eq('project_id', projectId) | |
.select() | |
.single(); | |
if (error) { | |
console.error('Error updating project:', error); | |
throw error; | |
} | |
if (!updatedData) { | |
throw new Error('No data returned from update'); | |
} | |
// Dispatch a custom event to notify components about the project change | |
if (typeof window !== 'undefined') { | |
window.dispatchEvent(new CustomEvent('project-updated', { | |
detail: { | |
projectId, | |
updatedData: { | |
id: updatedData.project_id, | |
name: updatedData.name, | |
description: updatedData.description | |
} | |
} | |
})); | |
} | |
// Return formatted project data - use same mapping as getProject | |
return { | |
id: updatedData.project_id, | |
name: updatedData.name, | |
description: updatedData.description || '', | |
account_id: updatedData.account_id, | |
created_at: updatedData.created_at, | |
sandbox: updatedData.sandbox || { id: "", pass: "", vnc_preview: "", sandbox_url: "" } | |
}; | |
}; | |
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[]> => { | |
const supabase = createClient(); | |
// Get the current user's ID to filter threads | |
const { data: userData, error: userError } = await supabase.auth.getUser(); | |
if (userError) { | |
console.error('Error getting current user:', userError); | |
return []; | |
} | |
// If no user is logged in, return an empty array | |
if (!userData.user) { | |
console.log('[API] No user logged in, returning empty threads array'); | |
return []; | |
} | |
let query = supabase.from('threads').select('*'); | |
// Always filter by the current user's account ID | |
query = query.eq('account_id', userData.user.id); | |
if (projectId) { | |
console.log('[API] Filtering threads by project_id:', projectId); | |
query = query.eq('project_id', projectId); | |
} | |
const { data, error } = await query; | |
if (error) { | |
console.error('[API] Error fetching threads:', error); | |
throw error; | |
} | |
console.log('[API] Raw threads from DB:', data?.length, data); | |
// Map database fields to ensure consistency with our Thread type | |
const mappedThreads: Thread[] = (data || []).map(thread => ({ | |
thread_id: thread.thread_id, | |
account_id: thread.account_id, | |
project_id: thread.project_id, | |
created_at: thread.created_at, | |
updated_at: thread.updated_at | |
})); | |
return mappedThreads; | |
}; | |
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}`); | |
} | |
}; | |
export const getMessages = async (threadId: string): Promise<Message[]> => { | |
const supabase = createClient(); | |
const { data, error } = await supabase | |
.from('messages') | |
.select('*') | |
.eq('thread_id', threadId) | |
.neq('type', 'cost') | |
.neq('type', 'summary') | |
.order('created_at', { ascending: true }); | |
if (error) { | |
console.error('Error fetching messages:', error); | |
throw new Error(`Error getting messages: ${error.message}`); | |
} | |
console.log('[API] Messages fetched:', data); | |
return data || []; | |
}; | |
// Agent APIs | |
export const startAgent = async ( | |
threadId: string, | |
options?: { | |
model_name?: string; | |
enable_thinking?: boolean; | |
reasoning_effort?: string; | |
stream?: boolean; | |
} | |
): 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}`, | |
}, | |
// Add cache: 'no-store' to prevent caching | |
cache: 'no-store', | |
// Add the body, stringifying the options or an empty object | |
body: JSON.stringify(options || {}), | |
}); | |
if (!response.ok) { | |
// Check for 402 Payment Required first | |
if (response.status === 402) { | |
try { | |
const errorData = await response.json(); | |
console.error(`[API] Billing error starting agent (402):`, errorData); | |
// Ensure detail exists and has a message property | |
const detail = errorData?.detail || { message: 'Payment Required' }; | |
if (typeof detail.message !== 'string') { | |
detail.message = 'Payment Required'; // Default message if missing | |
} | |
throw new BillingError(response.status, detail); | |
} catch (parseError) { | |
// Handle cases where parsing fails or the structure isn't as expected | |
console.error('[API] Could not parse 402 error response body:', parseError); | |
throw new BillingError(response.status, { message: 'Payment Required' }, `Error starting agent: ${response.statusText} (402)`); | |
} | |
} | |
// Handle other errors | |
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})`); | |
} | |
return response.json(); | |
} catch (error) { | |
// Rethrow BillingError instances directly | |
if (error instanceof BillingError) { | |
throw 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.`); | |
} | |
// Rethrow other caught errors | |
throw error; | |
} | |
}; | |
export const stopAgent = async (agentRunId: string): Promise<void> => { | |
// Add to non-running set immediately to prevent reconnection attempts | |
nonRunningAgentRuns.add(agentRunId); | |
// Close any existing stream | |
const existingStream = activeStreams.get(agentRunId); | |
if (existingStream) { | |
console.log(`[API] Closing existing stream for ${agentRunId} before stopping agent`); | |
existingStream.close(); | |
activeStreams.delete(agentRunId); | |
} | |
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}`, | |
}, | |
// Add cache: 'no-store' to prevent caching | |
cache: 'no-store', | |
}); | |
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}`); | |
// If we already know this agent is not running, throw an error | |
if (nonRunningAgentRuns.has(agentRunId)) { | |
console.log(`[API] Agent run ${agentRunId} is known to be non-running, returning error`); | |
throw new Error(`Agent run ${agentRunId} is not running`); | |
} | |
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}`, | |
}, | |
// Add cache: 'no-store' to prevent caching | |
cache: 'no-store', | |
}); | |
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); | |
// If we get a 404, add to non-running set | |
if (response.status === 404) { | |
nonRunningAgentRuns.add(agentRunId); | |
} | |
throw new Error(`Error getting agent status: ${response.statusText} (${response.status})`); | |
} | |
const data = await response.json(); | |
console.log(`[API] Successfully got agent status:`, data); | |
// If agent is not running, add to non-running set | |
if (data.status !== 'running') { | |
nonRunningAgentRuns.add(agentRunId); | |
} | |
return data; | |
} catch (error) { | |
console.error('[API] Failed to get agent status:', error); | |
throw error; | |
} | |
}; | |
export const getAgentRuns = async (threadId: string): Promise<AgentRun[]> => { | |
try { | |
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}`, | |
}, | |
// Add cache: 'no-store' to prevent caching | |
cache: 'no-store', | |
}); | |
if (!response.ok) { | |
throw new Error(`Error getting agent runs: ${response.statusText}`); | |
} | |
const data = await response.json(); | |
return data.agent_runs || []; | |
} catch (error) { | |
console.error('Failed to get agent runs:', error); | |
throw error; | |
} | |
}; | |
export const streamAgent = (agentRunId: string, callbacks: { | |
onMessage: (content: string) => void; | |
onError: (error: Error | string) => void; | |
onClose: () => void; | |
}): () => void => { | |
console.log(`[STREAM] streamAgent called for ${agentRunId}`); | |
// Check if this agent run is known to be non-running | |
if (nonRunningAgentRuns.has(agentRunId)) { | |
console.log(`[STREAM] Agent run ${agentRunId} is known to be non-running, not creating stream`); | |
// Notify the caller immediately | |
setTimeout(() => { | |
callbacks.onError(`Agent run ${agentRunId} is not running`); | |
callbacks.onClose(); | |
}, 0); | |
// Return a no-op cleanup function | |
return () => {}; | |
} | |
// Check if there's already an active stream for this agent run | |
const existingStream = activeStreams.get(agentRunId); | |
if (existingStream) { | |
console.log(`[STREAM] Stream already exists for ${agentRunId}, closing it first`); | |
existingStream.close(); | |
activeStreams.delete(agentRunId); | |
} | |
// Set up a new stream | |
try { | |
const setupStream = async () => { | |
// First verify the agent is actually running | |
try { | |
const status = await getAgentStatus(agentRunId); | |
if (status.status !== 'running') { | |
console.log(`[STREAM] Agent run ${agentRunId} is not running (status: ${status.status}), not creating stream`); | |
nonRunningAgentRuns.add(agentRunId); | |
callbacks.onError(`Agent run ${agentRunId} is not running (status: ${status.status})`); | |
callbacks.onClose(); | |
return; | |
} | |
} catch (err) { | |
console.error(`[STREAM] Error verifying agent run ${agentRunId}:`, err); | |
// Check if this is a "not found" error | |
const errorMessage = err instanceof Error ? err.message : String(err); | |
const isNotFoundError = errorMessage.includes('not found') || | |
errorMessage.includes('404') || | |
errorMessage.includes('does not exist'); | |
if (isNotFoundError) { | |
console.log(`[STREAM] Agent run ${agentRunId} not found, not creating stream`); | |
nonRunningAgentRuns.add(agentRunId); | |
} | |
callbacks.onError(errorMessage); | |
callbacks.onClose(); | |
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}`); | |
const eventSource = new EventSource(url.toString()); | |
// Store the EventSource in the active streams map | |
activeStreams.set(agentRunId, eventSource); | |
eventSource.onopen = () => { | |
console.log(`[STREAM] Connection opened for ${agentRunId}`); | |
}; | |
eventSource.onmessage = (event) => { | |
try { | |
const rawData = event.data; | |
if (rawData.includes('"type":"ping"')) return; | |
// Log raw data for debugging (truncated for readability) | |
console.log(`[STREAM] Received data for ${agentRunId}: ${rawData.substring(0, 100)}${rawData.length > 100 ? '...' : ''}`); | |
// Skip empty messages | |
if (!rawData || rawData.trim() === '') { | |
console.debug('[STREAM] Received empty message, skipping'); | |
return; | |
} | |
// Check for "Agent run not found" error | |
if (rawData.includes('Agent run') && rawData.includes('not found in active runs')) { | |
console.log(`[STREAM] Agent run ${agentRunId} not found in active runs, closing stream`); | |
// Add to non-running set to prevent future reconnection attempts | |
nonRunningAgentRuns.add(agentRunId); | |
// Notify about the error | |
callbacks.onError("Agent run not found in active runs"); | |
// Clean up | |
eventSource.close(); | |
activeStreams.delete(agentRunId); | |
callbacks.onClose(); | |
return; | |
} | |
// Check for completion messages | |
if (rawData.includes('"type":"status"') && rawData.includes('"status":"completed"')) { | |
console.log(`[STREAM] Detected completion status message for ${agentRunId}`); | |
// Check for specific completion messages that indicate we should stop checking | |
if (rawData.includes('Run data not available for streaming') || | |
rawData.includes('Stream ended with status: completed')) { | |
console.log(`[STREAM] Detected final completion message for ${agentRunId}, adding to non-running set`); | |
// Add to non-running set to prevent future reconnection attempts | |
nonRunningAgentRuns.add(agentRunId); | |
} | |
// Notify about the message | |
callbacks.onMessage(rawData); | |
// Clean up | |
eventSource.close(); | |
activeStreams.delete(agentRunId); | |
callbacks.onClose(); | |
return; | |
} | |
// Check for thread run end message | |
if (rawData.includes('"type":"status"') && rawData.includes('"status_type":"thread_run_end"')) { | |
console.log(`[STREAM] Detected thread run end message for ${agentRunId}`); | |
// Add to non-running set | |
nonRunningAgentRuns.add(agentRunId); | |
// Notify about the message | |
callbacks.onMessage(rawData); | |
// Clean up | |
eventSource.close(); | |
activeStreams.delete(agentRunId); | |
callbacks.onClose(); | |
return; | |
} | |
// For all other messages, just pass them through | |
callbacks.onMessage(rawData); | |
} catch (error) { | |
console.error(`[STREAM] Error handling message:`, error); | |
callbacks.onError(error instanceof Error ? error : String(error)); | |
} | |
}; | |
eventSource.onerror = (event) => { | |
console.log(`[STREAM] EventSource error for ${agentRunId}:`, event); | |
// Check if the agent is still running | |
getAgentStatus(agentRunId) | |
.then(status => { | |
if (status.status !== 'running') { | |
console.log(`[STREAM] Agent run ${agentRunId} is not running after error, closing stream`); | |
nonRunningAgentRuns.add(agentRunId); | |
eventSource.close(); | |
activeStreams.delete(agentRunId); | |
callbacks.onClose(); | |
} else { | |
console.log(`[STREAM] Agent run ${agentRunId} is still running after error, keeping stream open`); | |
// Let the browser handle reconnection for non-fatal errors | |
} | |
}) | |
.catch(err => { | |
console.error(`[STREAM] Error checking agent status after stream error:`, err); | |
// Check if this is a "not found" error | |
const errMsg = err instanceof Error ? err.message : String(err); | |
const isNotFoundErr = errMsg.includes('not found') || | |
errMsg.includes('404') || | |
errMsg.includes('does not exist'); | |
if (isNotFoundErr) { | |
console.log(`[STREAM] Agent run ${agentRunId} not found after error, closing stream`); | |
nonRunningAgentRuns.add(agentRunId); | |
eventSource.close(); | |
activeStreams.delete(agentRunId); | |
callbacks.onClose(); | |
} | |
// For other errors, notify but don't close the stream | |
callbacks.onError(errMsg); | |
}); | |
}; | |
}; | |
// Start the stream setup | |
setupStream(); | |
// Return a cleanup function | |
return () => { | |
console.log(`[STREAM] Cleanup called for ${agentRunId}`); | |
const stream = activeStreams.get(agentRunId); | |
if (stream) { | |
console.log(`[STREAM] Closing stream for ${agentRunId}`); | |
stream.close(); | |
activeStreams.delete(agentRunId); | |
} | |
}; | |
} catch (error) { | |
console.error(`[STREAM] Error setting up stream for ${agentRunId}:`, error); | |
callbacks.onError(error instanceof Error ? error : String(error)); | |
callbacks.onClose(); | |
return () => {}; | |
} | |
}; | |
// 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(); | |
// Use FormData to handle both text and binary content more reliably | |
const formData = new FormData(); | |
formData.append('path', filePath); | |
// Create a Blob from the content string and append as a file | |
const blob = new Blob([content], { type: 'application/octet-stream' }); | |
formData.append('file', blob, filePath.split('/').pop() || 'file'); | |
const headers: Record<string, string> = {}; | |
if (session?.access_token) { | |
headers['Authorization'] = `Bearer ${session.access_token}`; | |
} | |
const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, { | |
method: 'POST', | |
headers, | |
body: formData, | |
}); | |
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; | |
} | |
}; | |
// Fallback method for legacy support using JSON | |
export const createSandboxFileJson = async (sandboxId: string, filePath: string, content: string): Promise<void> => { | |
try { | |
const supabase = createClient(); | |
const { data: { session } } = await supabase.auth.getSession(); | |
const headers: Record<string, string> = { | |
'Content-Type': 'application/json' | |
}; | |
if (session?.access_token) { | |
headers['Authorization'] = `Bearer ${session.access_token}`; | |
} | |
const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files/json`, { | |
method: 'POST', | |
headers, | |
body: JSON.stringify({ | |
path: filePath, | |
content: content | |
}), | |
}); | |
if (!response.ok) { | |
const errorText = await response.text().catch(() => 'No error details available'); | |
console.error(`Error creating sandbox file (JSON): ${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 with JSON:', error); | |
throw error; | |
} | |
}; | |
export const listSandboxFiles = async (sandboxId: string, path: string): Promise<FileInfo[]> => { | |
try { | |
const supabase = createClient(); | |
const { data: { session } } = await supabase.auth.getSession(); | |
const url = new URL(`${API_URL}/sandboxes/${sandboxId}/files`); | |
url.searchParams.append('path', path); | |
const headers: Record<string, string> = {}; | |
if (session?.access_token) { | |
headers['Authorization'] = `Bearer ${session.access_token}`; | |
} | |
const response = await fetch(url.toString(), { | |
headers, | |
}); | |
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(); | |
const url = new URL(`${API_URL}/sandboxes/${sandboxId}/files/content`); | |
url.searchParams.append('path', path); | |
const headers: Record<string, string> = {}; | |
if (session?.access_token) { | |
headers['Authorization'] = `Bearer ${session.access_token}`; | |
} | |
const response = await fetch(url.toString(), { | |
headers, | |
}); | |
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; | |
} | |
}; | |
export const updateThread = async (threadId: string, data: Partial<Thread>): Promise<Thread> => { | |
const supabase = createClient(); | |
// Format the data for update | |
const updateData = { ...data }; | |
// Update the thread | |
const { data: updatedThread, error } = await supabase | |
.from('threads') | |
.update(updateData) | |
.eq('thread_id', threadId) | |
.select() | |
.single(); | |
if (error) { | |
console.error('Error updating thread:', error); | |
throw new Error(`Error updating thread: ${error.message}`); | |
} | |
return updatedThread; | |
}; | |
export const toggleThreadPublicStatus = async (threadId: string, isPublic: boolean): Promise<Thread> => { | |
return updateThread(threadId, { is_public: isPublic }); | |
}; | |
// Function to get public projects | |
export const getPublicProjects = async (): Promise<Project[]> => { | |
try { | |
const supabase = createClient(); | |
// Query for threads that are marked as public | |
const { data: publicThreads, error: threadsError } = await supabase | |
.from('threads') | |
.select('project_id') | |
.eq('is_public', true); | |
if (threadsError) { | |
console.error('Error fetching public threads:', threadsError); | |
return []; | |
} | |
// If no public threads found, return empty array | |
if (!publicThreads?.length) { | |
return []; | |
} | |
// Extract unique project IDs from public threads | |
const publicProjectIds = [...new Set(publicThreads.map(thread => thread.project_id))].filter(Boolean); | |
// If no valid project IDs, return empty array | |
if (!publicProjectIds.length) { | |
return []; | |
} | |
// Get the projects that have public threads | |
const { data: projects, error: projectsError } = await supabase | |
.from('projects') | |
.select('*') | |
.in('project_id', publicProjectIds); | |
if (projectsError) { | |
console.error('Error fetching public projects:', projectsError); | |
return []; | |
} | |
console.log('[API] Raw public projects from DB:', projects?.length, projects); | |
// Map database fields to our Project type | |
const mappedProjects: Project[] = (projects || []).map(project => ({ | |
id: project.project_id, | |
name: project.name || '', | |
description: project.description || '', | |
account_id: project.account_id, | |
created_at: project.created_at, | |
updated_at: project.updated_at, | |
sandbox: project.sandbox || { id: "", pass: "", vnc_preview: "", sandbox_url: "" }, | |
is_public: true // Mark these as public projects | |
})); | |
console.log('[API] Mapped public projects for frontend:', mappedProjects.length); | |
return mappedProjects; | |
} catch (err) { | |
console.error('Error fetching public projects:', err); | |
return []; | |
} | |
}; | |
export const initiateAgent = async (formData: FormData): Promise<InitiateAgentResponse> => { | |
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] Initiating agent with files using ${API_URL}/agent/initiate`); | |
const response = await fetch(`${API_URL}/agent/initiate`, { | |
method: 'POST', | |
headers: { | |
// Note: Don't set Content-Type for FormData | |
'Authorization': `Bearer ${session.access_token}`, | |
}, | |
body: formData, | |
// Add cache: 'no-store' to prevent caching | |
cache: 'no-store', | |
}); | |
if (!response.ok) { | |
const errorText = await response.text().catch(() => 'No error details available'); | |
console.error(`[API] Error initiating agent: ${response.status} ${response.statusText}`, errorText); | |
throw new Error(`Error initiating agent: ${response.statusText} (${response.status})`); | |
} | |
return response.json(); | |
} catch (error) { | |
console.error('[API] Failed to initiate 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 checkApiHealth = async (): Promise<HealthCheckResponse> => { | |
try { | |
const response = await fetch(`${API_URL}/health`, { | |
cache: 'no-store', | |
}); | |
if (!response.ok) { | |
throw new Error(`API health check failed: ${response.statusText}`); | |
} | |
return response.json(); | |
} catch (error) { | |
console.error('API health check failed:', error); | |
throw error; | |
} | |
}; | |