|
|
import { createClient } from '@/lib/supabase/client'; |
|
|
import { handleApiError } from './error-handler'; |
|
|
|
|
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; |
|
|
|
|
|
|
|
|
const nonRunningAgentRuns = new Set<string>(); |
|
|
|
|
|
const activeStreams = new Map<string, EventSource>(); |
|
|
|
|
|
|
|
|
export class BillingError extends Error { |
|
|
status: number; |
|
|
detail: { message: string; [key: string]: any }; |
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
Object.setPrototypeOf(this, BillingError.prototype); |
|
|
} |
|
|
} |
|
|
|
|
|
export class NoAccessTokenAvailableError extends Error { |
|
|
constructor(message?: string, options?: { cause?: Error }) { |
|
|
super(message || 'No access token available', options); |
|
|
} |
|
|
name = 'NoAccessTokenAvailableError'; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
[key: string]: any; |
|
|
}; |
|
|
|
|
|
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; |
|
|
}; |
|
|
|
|
|
export type Message = { |
|
|
role: string; |
|
|
content: string; |
|
|
type: string; |
|
|
agent_id?: string; |
|
|
agents?: { |
|
|
name: string; |
|
|
avatar?: string; |
|
|
avatar_color?: 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; |
|
|
} |
|
|
|
|
|
export type WorkflowExecution = { |
|
|
id: string; |
|
|
workflow_id: string; |
|
|
workflow_name: string; |
|
|
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; |
|
|
started_at: string | null; |
|
|
completed_at: string | null; |
|
|
result: any; |
|
|
error: string | null; |
|
|
}; |
|
|
|
|
|
export type WorkflowExecutionLog = { |
|
|
id: string; |
|
|
execution_id: string; |
|
|
node_id: string; |
|
|
node_name: string; |
|
|
node_type: string; |
|
|
started_at: string; |
|
|
completed_at: string | null; |
|
|
status: 'running' | 'completed' | 'failed'; |
|
|
input_data: any; |
|
|
output_data: any; |
|
|
error: string | null; |
|
|
}; |
|
|
|
|
|
|
|
|
export type Workflow = { |
|
|
id: string; |
|
|
name: string; |
|
|
description: string; |
|
|
status: 'draft' | 'active' | 'paused' | 'disabled' | 'archived'; |
|
|
project_id: string; |
|
|
account_id: string; |
|
|
definition: { |
|
|
name: string; |
|
|
description: string; |
|
|
nodes: any[]; |
|
|
edges: any[]; |
|
|
variables?: Record<string, any>; |
|
|
}; |
|
|
created_at: string; |
|
|
updated_at: string; |
|
|
}; |
|
|
|
|
|
export type WorkflowNode = { |
|
|
id: string; |
|
|
type: string; |
|
|
position: { x: number; y: number }; |
|
|
data: any; |
|
|
}; |
|
|
|
|
|
export type WorkflowEdge = { |
|
|
id: string; |
|
|
source: string; |
|
|
target: string; |
|
|
sourceHandle?: string; |
|
|
targetHandle?: string; |
|
|
}; |
|
|
|
|
|
|
|
|
export const getProjects = async (): Promise<Project[]> => { |
|
|
try { |
|
|
const supabase = createClient(); |
|
|
|
|
|
|
|
|
const { data: userData, error: userError } = await supabase.auth.getUser(); |
|
|
if (userError) { |
|
|
console.error('Error getting current user:', userError); |
|
|
return []; |
|
|
} |
|
|
|
|
|
|
|
|
if (!userData.user) { |
|
|
console.log('[API] No user logged in, returning empty projects array'); |
|
|
return []; |
|
|
} |
|
|
|
|
|
|
|
|
const { data, error } = await supabase |
|
|
.from('projects') |
|
|
.select('*') |
|
|
.eq('account_id', userData.user.id); |
|
|
|
|
|
if (error) { |
|
|
|
|
|
if ( |
|
|
error.code === '42501' && |
|
|
error.message.includes('has_role_on_account') |
|
|
) { |
|
|
console.error( |
|
|
'Permission error: User does not have proper account access', |
|
|
); |
|
|
return []; |
|
|
} |
|
|
throw error; |
|
|
} |
|
|
|
|
|
console.log('[API] Raw projects from DB:', data?.length, data); |
|
|
|
|
|
|
|
|
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); |
|
|
handleApiError(err, { operation: 'load projects', resource: 'projects' }); |
|
|
|
|
|
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) { |
|
|
|
|
|
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 (data.sandbox?.id) { |
|
|
|
|
|
const ensureSandboxActive = async () => { |
|
|
try { |
|
|
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}`; |
|
|
} |
|
|
|
|
|
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); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
ensureSandboxActive(); |
|
|
} |
|
|
|
|
|
|
|
|
const mappedProject: Project = { |
|
|
id: data.project_id, |
|
|
name: data.name || '', |
|
|
description: data.description || '', |
|
|
account_id: data.account_id, |
|
|
is_public: data.is_public || false, |
|
|
created_at: data.created_at, |
|
|
sandbox: data.sandbox || { |
|
|
id: '', |
|
|
pass: '', |
|
|
vnc_preview: '', |
|
|
sandbox_url: '', |
|
|
}, |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
return mappedProject; |
|
|
} catch (error) { |
|
|
console.error(`Error fetching project ${projectId}:`, error); |
|
|
handleApiError(error, { operation: 'load project', resource: `project ${projectId}` }); |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
export const createProject = async ( |
|
|
projectData: { name: string; description: string }, |
|
|
accountId?: string, |
|
|
): Promise<Project> => { |
|
|
const supabase = createClient(); |
|
|
|
|
|
|
|
|
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'); |
|
|
|
|
|
|
|
|
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) { |
|
|
handleApiError(error, { operation: 'create project', resource: 'project' }); |
|
|
throw error; |
|
|
} |
|
|
|
|
|
const project = { |
|
|
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: '' }, |
|
|
}; |
|
|
return project; |
|
|
}; |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
handleApiError(error, { operation: 'update project', resource: `project ${projectId}` }); |
|
|
throw error; |
|
|
} |
|
|
|
|
|
if (!updatedData) { |
|
|
const noDataError = new Error('No data returned from update'); |
|
|
handleApiError(noDataError, { operation: 'update project', resource: `project ${projectId}` }); |
|
|
throw noDataError; |
|
|
} |
|
|
|
|
|
|
|
|
if (typeof window !== 'undefined') { |
|
|
window.dispatchEvent( |
|
|
new CustomEvent('project-updated', { |
|
|
detail: { |
|
|
projectId, |
|
|
updatedData: { |
|
|
id: updatedData.project_id, |
|
|
name: updatedData.name, |
|
|
description: updatedData.description, |
|
|
}, |
|
|
}, |
|
|
}), |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
const project = { |
|
|
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: '', |
|
|
}, |
|
|
}; |
|
|
return project; |
|
|
}; |
|
|
|
|
|
export const deleteProject = async (projectId: string): Promise<void> => { |
|
|
const supabase = createClient(); |
|
|
const { error } = await supabase |
|
|
.from('projects') |
|
|
.delete() |
|
|
.eq('project_id', projectId); |
|
|
|
|
|
if (error) { |
|
|
handleApiError(error, { operation: 'delete project', resource: `project ${projectId}` }); |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
export const getThreads = async (projectId?: string): Promise<Thread[]> => { |
|
|
const supabase = createClient(); |
|
|
|
|
|
|
|
|
const { data: userData, error: userError } = await supabase.auth.getUser(); |
|
|
if (userError) { |
|
|
console.error('Error getting current user:', userError); |
|
|
return []; |
|
|
} |
|
|
|
|
|
|
|
|
if (!userData.user) { |
|
|
console.log('[API] No user logged in, returning empty threads array'); |
|
|
return []; |
|
|
} |
|
|
|
|
|
let query = supabase.from('threads').select('*'); |
|
|
|
|
|
|
|
|
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) { |
|
|
handleApiError(error, { operation: 'load threads', resource: projectId ? `threads for project ${projectId}` : 'threads' }); |
|
|
throw error; |
|
|
} |
|
|
|
|
|
const mappedThreads: Thread[] = (data || []) |
|
|
.filter((thread) => { |
|
|
const metadata = thread.metadata || {}; |
|
|
return !metadata.is_agent_builder; |
|
|
}) |
|
|
.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, |
|
|
metadata: thread.metadata, |
|
|
})); |
|
|
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) { |
|
|
handleApiError(error, { operation: 'load thread', resource: `thread ${threadId}` }); |
|
|
throw error; |
|
|
} |
|
|
|
|
|
return data; |
|
|
}; |
|
|
|
|
|
export const createThread = async (projectId: string): Promise<Thread> => { |
|
|
const supabase = createClient(); |
|
|
|
|
|
|
|
|
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, |
|
|
}) |
|
|
.select() |
|
|
.single(); |
|
|
|
|
|
if (error) { |
|
|
handleApiError(error, { operation: 'create thread', resource: 'thread' }); |
|
|
throw error; |
|
|
} |
|
|
return data; |
|
|
}; |
|
|
|
|
|
export const addUserMessage = async ( |
|
|
threadId: string, |
|
|
content: string, |
|
|
): Promise<void> => { |
|
|
const supabase = createClient(); |
|
|
|
|
|
|
|
|
const message = { |
|
|
role: 'user', |
|
|
content: content, |
|
|
}; |
|
|
|
|
|
|
|
|
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); |
|
|
handleApiError(error, { operation: 'add message', resource: 'message' }); |
|
|
throw new Error(`Error adding message: ${error.message}`); |
|
|
} |
|
|
}; |
|
|
|
|
|
export const getMessages = async (threadId: string): Promise<Message[]> => { |
|
|
const supabase = createClient(); |
|
|
|
|
|
let allMessages: Message[] = []; |
|
|
let from = 0; |
|
|
const batchSize = 1000; |
|
|
let hasMore = true; |
|
|
|
|
|
while (hasMore) { |
|
|
const { data, error } = await supabase |
|
|
.from('messages') |
|
|
.select(` |
|
|
*, |
|
|
agents:agent_id ( |
|
|
name, |
|
|
avatar, |
|
|
avatar_color |
|
|
) |
|
|
`) |
|
|
.eq('thread_id', threadId) |
|
|
.neq('type', 'cost') |
|
|
.neq('type', 'summary') |
|
|
.order('created_at', { ascending: true }) |
|
|
.range(from, from + batchSize - 1); |
|
|
|
|
|
if (error) { |
|
|
console.error('Error fetching messages:', error); |
|
|
handleApiError(error, { operation: 'load messages', resource: `messages for thread ${threadId}` }); |
|
|
throw new Error(`Error getting messages: ${error.message}`); |
|
|
} |
|
|
|
|
|
if (data && data.length > 0) { |
|
|
allMessages = allMessages.concat(data); |
|
|
from += batchSize; |
|
|
hasMore = data.length === batchSize; |
|
|
} else { |
|
|
hasMore = false; |
|
|
} |
|
|
} |
|
|
|
|
|
console.log('[API] Messages fetched count:', allMessages.length); |
|
|
|
|
|
return allMessages; |
|
|
}; |
|
|
|
|
|
|
|
|
export const startAgent = async ( |
|
|
threadId: string, |
|
|
options?: { |
|
|
model_name?: string; |
|
|
enable_thinking?: boolean; |
|
|
reasoning_effort?: string; |
|
|
stream?: boolean; |
|
|
agent_id?: string; |
|
|
}, |
|
|
): Promise<{ agent_run_id: string }> => { |
|
|
try { |
|
|
const supabase = createClient(); |
|
|
const { |
|
|
data: { session }, |
|
|
} = await supabase.auth.getSession(); |
|
|
|
|
|
if (!session?.access_token) { |
|
|
throw new NoAccessTokenAvailableError(); |
|
|
} |
|
|
|
|
|
|
|
|
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 defaultOptions = { |
|
|
model_name: 'claude-3-7-sonnet-latest', |
|
|
enable_thinking: false, |
|
|
reasoning_effort: 'low', |
|
|
stream: true, |
|
|
agent_id: undefined, |
|
|
}; |
|
|
|
|
|
const finalOptions = { ...defaultOptions, ...options }; |
|
|
|
|
|
const body: any = { |
|
|
model_name: finalOptions.model_name, |
|
|
enable_thinking: finalOptions.enable_thinking, |
|
|
reasoning_effort: finalOptions.reasoning_effort, |
|
|
stream: finalOptions.stream, |
|
|
}; |
|
|
|
|
|
|
|
|
if (finalOptions.agent_id) { |
|
|
body.agent_id = finalOptions.agent_id; |
|
|
} |
|
|
|
|
|
const response = await fetch(`${API_URL}/thread/${threadId}/agent/start`, { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
body: JSON.stringify(body), |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
|
|
|
if (response.status === 402) { |
|
|
try { |
|
|
const errorData = await response.json(); |
|
|
console.error(`[API] Billing error starting agent (402):`, errorData); |
|
|
|
|
|
const detail = errorData?.detail || { message: 'Payment Required' }; |
|
|
if (typeof detail.message !== 'string') { |
|
|
detail.message = 'Payment Required'; |
|
|
} |
|
|
throw new BillingError(response.status, detail); |
|
|
} catch (parseError) { |
|
|
|
|
|
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)`, |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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})`, |
|
|
); |
|
|
} |
|
|
|
|
|
const result = await response.json(); |
|
|
return result; |
|
|
} catch (error) { |
|
|
|
|
|
if (error instanceof BillingError) { |
|
|
throw error; |
|
|
} |
|
|
|
|
|
if (error instanceof NoAccessTokenAvailableError) { |
|
|
throw error; |
|
|
} |
|
|
|
|
|
console.error('[API] Failed to start agent:', error); |
|
|
|
|
|
|
|
|
if ( |
|
|
error instanceof TypeError && |
|
|
error.message.includes('Failed to fetch') |
|
|
) { |
|
|
const networkError = new Error( |
|
|
`Cannot connect to backend server. Please check your internet connection and make sure the backend is running.`, |
|
|
); |
|
|
handleApiError(networkError, { operation: 'start agent', resource: 'AI assistant' }); |
|
|
throw networkError; |
|
|
} |
|
|
|
|
|
|
|
|
handleApiError(error, { operation: 'start agent', resource: 'AI assistant' }); |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
export const stopAgent = async (agentRunId: string): Promise<void> => { |
|
|
|
|
|
nonRunningAgentRuns.add(agentRunId); |
|
|
|
|
|
|
|
|
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) { |
|
|
const authError = new NoAccessTokenAvailableError(); |
|
|
handleApiError(authError, { operation: 'stop agent', resource: 'AI assistant' }); |
|
|
throw authError; |
|
|
} |
|
|
|
|
|
const response = await fetch(`${API_URL}/agent-run/${agentRunId}/stop`, { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
|
|
|
cache: 'no-store', |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const stopError = new Error(`Error stopping agent: ${response.statusText}`); |
|
|
handleApiError(stopError, { operation: 'stop agent', resource: 'AI assistant' }); |
|
|
throw stopError; |
|
|
} |
|
|
}; |
|
|
|
|
|
export const getAgentStatus = async (agentRunId: string): Promise<AgentRun> => { |
|
|
console.log(`[API] Requesting agent status for ${agentRunId}`); |
|
|
|
|
|
|
|
|
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 NoAccessTokenAvailableError(); |
|
|
} |
|
|
|
|
|
const url = `${API_URL}/agent-run/${agentRunId}`; |
|
|
console.log(`[API] Fetching from: ${url}`); |
|
|
|
|
|
const response = await fetch(url, { |
|
|
headers: { |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
|
|
|
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 (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 (data.status !== 'running') { |
|
|
nonRunningAgentRuns.add(agentRunId); |
|
|
} |
|
|
|
|
|
return data; |
|
|
} catch (error) { |
|
|
console.error('[API] Failed to get agent status:', error); |
|
|
handleApiError(error, { operation: 'get agent status', resource: 'AI assistant status', silent: true }); |
|
|
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 NoAccessTokenAvailableError(); |
|
|
} |
|
|
|
|
|
const response = await fetch(`${API_URL}/thread/${threadId}/agent-runs`, { |
|
|
headers: { |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
|
|
|
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) { |
|
|
if (error instanceof NoAccessTokenAvailableError) { |
|
|
throw error; |
|
|
} |
|
|
|
|
|
console.error('Failed to get agent runs:', error); |
|
|
handleApiError(error, { operation: 'load agent runs', resource: 'conversation history' }); |
|
|
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}`); |
|
|
|
|
|
|
|
|
if (nonRunningAgentRuns.has(agentRunId)) { |
|
|
console.log( |
|
|
`[STREAM] Agent run ${agentRunId} is known to be non-running, not creating stream`, |
|
|
); |
|
|
|
|
|
setTimeout(() => { |
|
|
callbacks.onError(`Agent run ${agentRunId} is not running`); |
|
|
callbacks.onClose(); |
|
|
}, 0); |
|
|
|
|
|
|
|
|
return () => {}; |
|
|
} |
|
|
|
|
|
|
|
|
const existingStream = activeStreams.get(agentRunId); |
|
|
if (existingStream) { |
|
|
console.log( |
|
|
`[STREAM] Stream already exists for ${agentRunId}, closing it first`, |
|
|
); |
|
|
existingStream.close(); |
|
|
activeStreams.delete(agentRunId); |
|
|
} |
|
|
|
|
|
|
|
|
try { |
|
|
const setupStream = async () => { |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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) { |
|
|
const authError = new NoAccessTokenAvailableError(); |
|
|
console.error('[STREAM] No auth token available'); |
|
|
callbacks.onError(authError); |
|
|
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()); |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
console.log( |
|
|
`[STREAM] Received data for ${agentRunId}: ${rawData.substring(0, 100)}${rawData.length > 100 ? '...' : ''}`, |
|
|
); |
|
|
|
|
|
|
|
|
if (!rawData || rawData.trim() === '') { |
|
|
console.debug('[STREAM] Received empty message, skipping'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
try { |
|
|
const jsonData = JSON.parse(rawData); |
|
|
if (jsonData.status === 'error') { |
|
|
console.error(`[STREAM] Error status received for ${agentRunId}:`, jsonData); |
|
|
|
|
|
|
|
|
callbacks.onError(jsonData.message || 'Unknown error occurred'); |
|
|
|
|
|
|
|
|
return; |
|
|
} |
|
|
} catch (jsonError) { |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
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`, |
|
|
); |
|
|
|
|
|
|
|
|
nonRunningAgentRuns.add(agentRunId); |
|
|
|
|
|
|
|
|
callbacks.onError('Agent run not found in active runs'); |
|
|
|
|
|
|
|
|
eventSource.close(); |
|
|
activeStreams.delete(agentRunId); |
|
|
callbacks.onClose(); |
|
|
|
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if ( |
|
|
rawData.includes('"type":"status"') && |
|
|
rawData.includes('"status":"completed"') |
|
|
) { |
|
|
console.log( |
|
|
`[STREAM] Detected completion status message for ${agentRunId}`, |
|
|
); |
|
|
|
|
|
|
|
|
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`, |
|
|
); |
|
|
|
|
|
nonRunningAgentRuns.add(agentRunId); |
|
|
} |
|
|
|
|
|
|
|
|
callbacks.onMessage(rawData); |
|
|
|
|
|
|
|
|
eventSource.close(); |
|
|
activeStreams.delete(agentRunId); |
|
|
callbacks.onClose(); |
|
|
|
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if ( |
|
|
rawData.includes('"type":"status"') && |
|
|
rawData.includes('"status_type":"thread_run_end"') |
|
|
) { |
|
|
console.log( |
|
|
`[STREAM] Detected thread run end message for ${agentRunId}`, |
|
|
); |
|
|
|
|
|
|
|
|
nonRunningAgentRuns.add(agentRunId); |
|
|
|
|
|
|
|
|
callbacks.onMessage(rawData); |
|
|
|
|
|
|
|
|
eventSource.close(); |
|
|
activeStreams.delete(agentRunId); |
|
|
callbacks.onClose(); |
|
|
|
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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`, |
|
|
); |
|
|
|
|
|
} |
|
|
}) |
|
|
.catch((err) => { |
|
|
console.error( |
|
|
`[STREAM] Error checking agent status after stream error:`, |
|
|
err, |
|
|
); |
|
|
|
|
|
|
|
|
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(); |
|
|
} |
|
|
|
|
|
|
|
|
callbacks.onError(errMsg); |
|
|
}); |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
setupStream(); |
|
|
|
|
|
|
|
|
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 () => {}; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
export const createSandboxFile = async ( |
|
|
sandboxId: string, |
|
|
filePath: string, |
|
|
content: string, |
|
|
): Promise<void> => { |
|
|
try { |
|
|
const supabase = createClient(); |
|
|
const { |
|
|
data: { session }, |
|
|
} = await supabase.auth.getSession(); |
|
|
|
|
|
|
|
|
const formData = new FormData(); |
|
|
formData.append('path', filePath); |
|
|
|
|
|
|
|
|
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})`, |
|
|
); |
|
|
} |
|
|
|
|
|
const result = await response.json(); |
|
|
return result; |
|
|
} catch (error) { |
|
|
console.error('Failed to create sandbox file:', error); |
|
|
handleApiError(error, { operation: 'create file', resource: `file ${filePath}` }); |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
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})`, |
|
|
); |
|
|
} |
|
|
|
|
|
const result = await response.json(); |
|
|
return result; |
|
|
} catch (error) { |
|
|
console.error('Failed to create sandbox file with JSON:', error); |
|
|
handleApiError(error, { operation: 'create file', resource: `file ${filePath}` }); |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
function normalizePathWithUnicode(path: string): string { |
|
|
try { |
|
|
|
|
|
return path.replace(/\\u([0-9a-fA-F]{4})/g, (_, hexCode) => { |
|
|
return String.fromCharCode(parseInt(hexCode, 16)); |
|
|
}); |
|
|
} catch (e) { |
|
|
console.error('Error processing Unicode escapes in path:', e); |
|
|
return path; |
|
|
} |
|
|
} |
|
|
|
|
|
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`); |
|
|
|
|
|
|
|
|
const normalizedPath = normalizePathWithUnicode(path); |
|
|
|
|
|
|
|
|
url.searchParams.append('path', normalizedPath); |
|
|
|
|
|
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`); |
|
|
|
|
|
|
|
|
const normalizedPath = normalizePathWithUnicode(path); |
|
|
|
|
|
|
|
|
url.searchParams.append('path', normalizedPath); |
|
|
|
|
|
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})`, |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
handleApiError(error, { operation: 'load file content', resource: `file ${path}` }); |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
export const getPublicProjects = async (): Promise<Project[]> => { |
|
|
try { |
|
|
const supabase = createClient(); |
|
|
|
|
|
|
|
|
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 (!publicThreads?.length) { |
|
|
return []; |
|
|
} |
|
|
|
|
|
|
|
|
const publicProjectIds = [ |
|
|
...new Set(publicThreads.map((thread) => thread.project_id)), |
|
|
].filter(Boolean); |
|
|
|
|
|
|
|
|
if (!publicProjectIds.length) { |
|
|
return []; |
|
|
} |
|
|
|
|
|
|
|
|
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, |
|
|
); |
|
|
|
|
|
|
|
|
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, |
|
|
})); |
|
|
|
|
|
console.log( |
|
|
'[API] Mapped public projects for frontend:', |
|
|
mappedProjects.length, |
|
|
); |
|
|
|
|
|
return mappedProjects; |
|
|
} catch (err) { |
|
|
console.error('Error fetching public projects:', err); |
|
|
handleApiError(err, { operation: 'load public projects', resource: 'public projects' }); |
|
|
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 NoAccessTokenAvailableError(); |
|
|
} |
|
|
|
|
|
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: { |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
body: formData, |
|
|
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, |
|
|
); |
|
|
|
|
|
if (response.status === 402) { |
|
|
throw new Error('Payment Required'); |
|
|
} else if (response.status === 401) { |
|
|
throw new Error('Authentication error: Please sign in again'); |
|
|
} else if (response.status >= 500) { |
|
|
throw new Error('Server error: Please try again later'); |
|
|
} |
|
|
|
|
|
throw new Error( |
|
|
`Error initiating agent: ${response.statusText} (${response.status})`, |
|
|
); |
|
|
} |
|
|
|
|
|
const result = await response.json(); |
|
|
return result; |
|
|
} catch (error) { |
|
|
console.error('[API] Failed to initiate agent:', error); |
|
|
|
|
|
if ( |
|
|
error instanceof TypeError && |
|
|
error.message.includes('Failed to fetch') |
|
|
) { |
|
|
const networkError = new Error( |
|
|
`Cannot connect to backend server. Please check your internet connection and make sure the backend is running.`, |
|
|
); |
|
|
handleApiError(networkError, { operation: 'initiate agent', resource: 'AI assistant' }); |
|
|
throw networkError; |
|
|
} |
|
|
handleApiError(error, { operation: 'initiate agent' }); |
|
|
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) { |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
export interface CreateCheckoutSessionRequest { |
|
|
price_id: string; |
|
|
success_url: string; |
|
|
cancel_url: string; |
|
|
referral_id?: string; |
|
|
} |
|
|
|
|
|
export interface CreatePortalSessionRequest { |
|
|
return_url: string; |
|
|
} |
|
|
|
|
|
export interface SubscriptionStatus { |
|
|
status: string; |
|
|
plan_name?: string; |
|
|
price_id?: string; |
|
|
current_period_end?: string; |
|
|
cancel_at_period_end: boolean; |
|
|
trial_end?: string; |
|
|
minutes_limit?: number; |
|
|
cost_limit?: number; |
|
|
current_usage?: number; |
|
|
|
|
|
has_schedule: boolean; |
|
|
scheduled_plan_name?: string; |
|
|
scheduled_price_id?: string; |
|
|
scheduled_change_date?: string; |
|
|
schedule_effective_date?: string; |
|
|
} |
|
|
|
|
|
export interface BillingStatusResponse { |
|
|
can_run: boolean; |
|
|
message: string; |
|
|
subscription: { |
|
|
price_id: string; |
|
|
plan_name: string; |
|
|
minutes_limit?: number; |
|
|
}; |
|
|
} |
|
|
|
|
|
export interface Model { |
|
|
id: string; |
|
|
display_name: string; |
|
|
short_name?: string; |
|
|
requires_subscription?: boolean; |
|
|
is_available?: boolean; |
|
|
input_cost_per_million_tokens?: number | null; |
|
|
output_cost_per_million_tokens?: number | null; |
|
|
max_tokens?: number | null; |
|
|
} |
|
|
|
|
|
export interface AvailableModelsResponse { |
|
|
models: Model[]; |
|
|
subscription_tier: string; |
|
|
total_models: number; |
|
|
} |
|
|
|
|
|
export interface UsageLogEntry { |
|
|
message_id: string; |
|
|
thread_id: string; |
|
|
created_at: string; |
|
|
content: { |
|
|
usage: { |
|
|
prompt_tokens: number; |
|
|
completion_tokens: number; |
|
|
}; |
|
|
model: string; |
|
|
}; |
|
|
total_tokens: number; |
|
|
estimated_cost: number; |
|
|
project_id: string; |
|
|
} |
|
|
|
|
|
export interface UsageLogsResponse { |
|
|
logs: UsageLogEntry[]; |
|
|
has_more: boolean; |
|
|
message?: string; |
|
|
} |
|
|
|
|
|
export interface CreateCheckoutSessionResponse { |
|
|
status: |
|
|
| 'upgraded' |
|
|
| 'downgrade_scheduled' |
|
|
| 'checkout_created' |
|
|
| 'no_change' |
|
|
| 'new' |
|
|
| 'updated' |
|
|
| 'scheduled'; |
|
|
subscription_id?: string; |
|
|
schedule_id?: string; |
|
|
session_id?: string; |
|
|
url?: string; |
|
|
effective_date?: string; |
|
|
message?: string; |
|
|
details?: { |
|
|
is_upgrade?: boolean; |
|
|
effective_date?: string; |
|
|
current_price?: number; |
|
|
new_price?: number; |
|
|
invoice?: { |
|
|
id: string; |
|
|
status: string; |
|
|
amount_due: number; |
|
|
amount_paid: number; |
|
|
}; |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
export const createCheckoutSession = async ( |
|
|
request: CreateCheckoutSessionRequest, |
|
|
): Promise<CreateCheckoutSessionResponse> => { |
|
|
try { |
|
|
const supabase = createClient(); |
|
|
const { |
|
|
data: { session }, |
|
|
} = await supabase.auth.getSession(); |
|
|
|
|
|
if (!session?.access_token) { |
|
|
throw new NoAccessTokenAvailableError(); |
|
|
} |
|
|
|
|
|
|
|
|
const requestBody = { ...request, tolt_referral: window.tolt_referral }; |
|
|
console.log('Tolt Referral ID:', requestBody.tolt_referral); |
|
|
|
|
|
const response = await fetch(`${API_URL}/billing/create-checkout-session`, { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
body: JSON.stringify(requestBody), |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response |
|
|
.text() |
|
|
.catch(() => 'No error details available'); |
|
|
console.error( |
|
|
`Error creating checkout session: ${response.status} ${response.statusText}`, |
|
|
errorText, |
|
|
); |
|
|
throw new Error( |
|
|
`Error creating checkout session: ${response.statusText} (${response.status})`, |
|
|
); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
console.log('Checkout session response:', data); |
|
|
|
|
|
|
|
|
switch (data.status) { |
|
|
case 'upgraded': |
|
|
case 'updated': |
|
|
case 'downgrade_scheduled': |
|
|
case 'scheduled': |
|
|
case 'no_change': |
|
|
return data; |
|
|
case 'new': |
|
|
case 'checkout_created': |
|
|
if (!data.url) { |
|
|
throw new Error('No checkout URL provided'); |
|
|
} |
|
|
return data; |
|
|
default: |
|
|
console.warn( |
|
|
'Unexpected status from createCheckoutSession:', |
|
|
data.status, |
|
|
); |
|
|
return data; |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Failed to create checkout session:', error); |
|
|
handleApiError(error, { operation: 'create checkout session', resource: 'billing' }); |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
export const createPortalSession = async ( |
|
|
request: CreatePortalSessionRequest, |
|
|
): Promise<{ url: string }> => { |
|
|
try { |
|
|
const supabase = createClient(); |
|
|
const { |
|
|
data: { session }, |
|
|
} = await supabase.auth.getSession(); |
|
|
|
|
|
if (!session?.access_token) { |
|
|
throw new NoAccessTokenAvailableError(); |
|
|
} |
|
|
|
|
|
const response = await fetch(`${API_URL}/billing/create-portal-session`, { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
body: JSON.stringify(request), |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response |
|
|
.text() |
|
|
.catch(() => 'No error details available'); |
|
|
console.error( |
|
|
`Error creating portal session: ${response.status} ${response.statusText}`, |
|
|
errorText, |
|
|
); |
|
|
throw new Error( |
|
|
`Error creating portal session: ${response.statusText} (${response.status})`, |
|
|
); |
|
|
} |
|
|
|
|
|
return response.json(); |
|
|
} catch (error) { |
|
|
console.error('Failed to create portal session:', error); |
|
|
handleApiError(error, { operation: 'create portal session', resource: 'billing portal' }); |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
export const getSubscription = async (): Promise<SubscriptionStatus> => { |
|
|
try { |
|
|
const supabase = createClient(); |
|
|
const { |
|
|
data: { session }, |
|
|
} = await supabase.auth.getSession(); |
|
|
|
|
|
if (!session?.access_token) { |
|
|
throw new NoAccessTokenAvailableError(); |
|
|
} |
|
|
|
|
|
const response = await fetch(`${API_URL}/billing/subscription`, { |
|
|
headers: { |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response |
|
|
.text() |
|
|
.catch(() => 'No error details available'); |
|
|
console.error( |
|
|
`Error getting subscription: ${response.status} ${response.statusText}`, |
|
|
errorText, |
|
|
); |
|
|
throw new Error( |
|
|
`Error getting subscription: ${response.statusText} (${response.status})`, |
|
|
); |
|
|
} |
|
|
|
|
|
return response.json(); |
|
|
} catch (error) { |
|
|
if (error instanceof NoAccessTokenAvailableError) { |
|
|
throw error; |
|
|
} |
|
|
|
|
|
console.error('Failed to get subscription:', error); |
|
|
handleApiError(error, { operation: 'load subscription', resource: 'billing information' }); |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
export const getAvailableModels = async (): Promise<AvailableModelsResponse> => { |
|
|
try { |
|
|
const supabase = createClient(); |
|
|
const { |
|
|
data: { session }, |
|
|
} = await supabase.auth.getSession(); |
|
|
|
|
|
if (!session?.access_token) { |
|
|
throw new NoAccessTokenAvailableError(); |
|
|
} |
|
|
|
|
|
const response = await fetch(`${API_URL}/billing/available-models`, { |
|
|
headers: { |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response |
|
|
.text() |
|
|
.catch(() => 'No error details available'); |
|
|
console.error( |
|
|
`Error getting available models: ${response.status} ${response.statusText}`, |
|
|
errorText, |
|
|
); |
|
|
throw new Error( |
|
|
`Error getting available models: ${response.statusText} (${response.status})`, |
|
|
); |
|
|
} |
|
|
|
|
|
return response.json(); |
|
|
} catch (error) { |
|
|
if (error instanceof NoAccessTokenAvailableError) { |
|
|
throw error; |
|
|
} |
|
|
|
|
|
console.error('Failed to get available models:', error); |
|
|
handleApiError(error, { operation: 'load available models', resource: 'AI models' }); |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
export const checkBillingStatus = async (): Promise<BillingStatusResponse> => { |
|
|
try { |
|
|
const supabase = createClient(); |
|
|
const { |
|
|
data: { session }, |
|
|
} = await supabase.auth.getSession(); |
|
|
|
|
|
if (!session?.access_token) { |
|
|
throw new NoAccessTokenAvailableError(); |
|
|
} |
|
|
|
|
|
const response = await fetch(`${API_URL}/billing/check-status`, { |
|
|
headers: { |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response |
|
|
.text() |
|
|
.catch(() => 'No error details available'); |
|
|
console.error( |
|
|
`Error checking billing status: ${response.status} ${response.statusText}`, |
|
|
errorText, |
|
|
); |
|
|
throw new Error( |
|
|
`Error checking billing status: ${response.statusText} (${response.status})`, |
|
|
); |
|
|
} |
|
|
|
|
|
return response.json(); |
|
|
} catch (error) { |
|
|
if (error instanceof NoAccessTokenAvailableError) { |
|
|
throw error; |
|
|
} |
|
|
|
|
|
console.error('Failed to check billing status:', error); |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
export interface TranscriptionResponse { |
|
|
text: string; |
|
|
} |
|
|
|
|
|
|
|
|
export const transcribeAudio = async (audioFile: File): Promise<TranscriptionResponse> => { |
|
|
try { |
|
|
const supabase = createClient(); |
|
|
const { |
|
|
data: { session }, |
|
|
} = await supabase.auth.getSession(); |
|
|
|
|
|
if (!session?.access_token) { |
|
|
throw new NoAccessTokenAvailableError(); |
|
|
} |
|
|
|
|
|
const formData = new FormData(); |
|
|
formData.append('audio_file', audioFile); |
|
|
|
|
|
const response = await fetch(`${API_URL}/transcription`, { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
body: formData, |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response |
|
|
.text() |
|
|
.catch(() => 'No error details available'); |
|
|
console.error( |
|
|
`Error transcribing audio: ${response.status} ${response.statusText}`, |
|
|
errorText, |
|
|
); |
|
|
throw new Error( |
|
|
`Error transcribing audio: ${response.statusText} (${response.status})`, |
|
|
); |
|
|
} |
|
|
|
|
|
return response.json(); |
|
|
} catch (error) { |
|
|
if (error instanceof NoAccessTokenAvailableError) { |
|
|
throw error; |
|
|
} |
|
|
|
|
|
console.error('Failed to transcribe audio:', error); |
|
|
handleApiError(error, { operation: 'transcribe audio', resource: 'speech-to-text' }); |
|
|
throw error; |
|
|
} |
|
|
}; |
|
|
|
|
|
export const getAgentBuilderChatHistory = async (agentId: string): Promise<{messages: Message[], thread_id: string | null}> => { |
|
|
const supabase = createClient(); |
|
|
const { |
|
|
data: { session }, |
|
|
} = await supabase.auth.getSession(); |
|
|
|
|
|
if (!session?.access_token) { |
|
|
throw new NoAccessTokenAvailableError(); |
|
|
} |
|
|
|
|
|
const response = await fetch(`${API_URL}/agents/${agentId}/builder-chat-history`, { |
|
|
headers: { |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response.text().catch(() => 'No error details available'); |
|
|
console.error(`Error getting agent builder chat history: ${response.status} ${response.statusText}`, errorText); |
|
|
throw new Error(`Error getting agent builder chat history: ${response.statusText}`); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
console.log('[API] Agent builder chat history fetched:', data); |
|
|
|
|
|
return data; |
|
|
}; |
|
|
|