-
-
-
+
+
+
-
- Processing replacement
+
+ Processing String Replacement
-
- Replacing text in {filePath || 'file'}
+
+
+ {filePath || 'Processing file...'}
+
+
+
+
+
+ Analyzing text patterns
+ {Math.round(Math.min(progress, 100))}%
+
+
+
+ Please wait while the replacement is being processed
-
-
{progress}%
);
-// Error state component
const ErrorState: React.FC = () => (
@@ -197,18 +202,18 @@ export function StrReplaceToolView({
const { oldStr, newStr } = extractStrReplaceContent(assistantContent);
const toolTitle = getToolTitle(name);
- // Simulate progress when streaming
useEffect(() => {
if (isStreaming) {
+ setProgress(0);
const timer = setInterval(() => {
setProgress((prevProgress) => {
if (prevProgress >= 95) {
clearInterval(timer);
return prevProgress;
}
- return prevProgress + 5;
+ return prevProgress + Math.random() * 10 + 5;
});
- }, 300);
+ }, 500);
return () => clearInterval(timer);
} else {
setProgress(100);
@@ -372,6 +377,13 @@ export function StrReplaceToolView({
{isSuccess ? 'Replacement completed' : 'Replacement failed'}
)}
+
+ {isStreaming && (
+
+
+ Processing replacement
+
+ )}
diff --git a/frontend/src/components/thread/tool-views/WebSearchToolView.tsx b/frontend/src/components/thread/tool-views/WebSearchToolView.tsx
index 2115a2a1..c12a5a37 100644
--- a/frontend/src/components/thread/tool-views/WebSearchToolView.tsx
+++ b/frontend/src/components/thread/tool-views/WebSearchToolView.tsx
@@ -178,7 +178,7 @@ export function WebSearchToolView({
{query}
-
+
{progress}%
diff --git a/frontend/src/hooks/react-query/dashboard/use-initiate-agent.ts b/frontend/src/hooks/react-query/dashboard/use-initiate-agent.ts
index 8e6fa4e6..cf9c3b01 100644
--- a/frontend/src/hooks/react-query/dashboard/use-initiate-agent.ts
+++ b/frontend/src/hooks/react-query/dashboard/use-initiate-agent.ts
@@ -2,7 +2,7 @@
import { initiateAgent, InitiateAgentResponse } from "@/lib/api";
import { createMutationHook } from "@/hooks/use-query";
-import { toast } from "sonner";
+import { handleApiSuccess, handleApiError } from "@/lib/error-handler";
import { dashboardKeys } from "./keys";
import { useQueryClient } from "@tanstack/react-query";
import { useModal } from "@/hooks/use-modal-store";
@@ -14,25 +14,15 @@ export const useInitiateAgentMutation = createMutationHook<
>(
initiateAgent,
{
+ errorContext: { operation: 'initiate agent', resource: 'AI assistant' },
onSuccess: (data) => {
- toast.success("Agent initiated successfully");
+ handleApiSuccess("Agent initiated successfully", "Your AI assistant is ready to help");
},
onError: (error) => {
- if (error instanceof Error) {
- const errorMessage = error.message;
- if (errorMessage.toLowerCase().includes("payment required")) {
- return;
- }
- if (errorMessage.includes("Cannot connect to backend server")) {
- toast.error("Connection error: Please check your internet connection and ensure the backend server is running");
- } else if (errorMessage.includes("No access token available")) {
- toast.error("Authentication error: Please sign in again");
- } else {
- toast.error(`Failed to initiate agent: ${errorMessage}`);
- }
- } else {
- toast.error("An unexpected error occurred while initiating the agent");
+ if (error instanceof Error && error.message.toLowerCase().includes("payment required")) {
+ return;
}
+ handleApiError(error, { operation: 'initiate agent', resource: 'AI assistant' });
}
}
);
diff --git a/frontend/src/hooks/use-query.ts b/frontend/src/hooks/use-query.ts
index a587aeb0..da25ed63 100644
--- a/frontend/src/hooks/use-query.ts
+++ b/frontend/src/hooks/use-query.ts
@@ -7,7 +7,7 @@ import {
UseMutationOptions,
QueryKey,
} from '@tanstack/react-query';
-import { toast } from 'sonner';
+import { handleApiError, ErrorContext } from '@/lib/error-handler';
type QueryKeyValue = readonly unknown[];
type QueryKeyFunction = (...args: any[]) => QueryKeyValue;
@@ -54,25 +54,33 @@ export function createMutationHook<
options?: Omit<
UseMutationOptions
,
'mutationFn'
- >,
+ > & {
+ errorContext?: ErrorContext;
+ },
) {
return (
customOptions?: Omit<
UseMutationOptions,
'mutationFn'
- >,
+ > & {
+ errorContext?: ErrorContext;
+ },
) => {
+ const { errorContext: baseErrorContext, ...baseOptions } = options || {};
+ const { errorContext: customErrorContext, ...customMutationOptions } = customOptions || {};
+
return useMutation({
mutationFn,
onError: (error, variables, context) => {
- toast.error(
- `An error occurred: ${error instanceof Error ? error.message : String(error)}`,
- );
- options?.onError?.(error, variables, context);
- customOptions?.onError?.(error, variables, context);
+ const errorContext = customErrorContext || baseErrorContext;
+ if (!customMutationOptions?.onError && !baseOptions?.onError) {
+ handleApiError(error, errorContext);
+ }
+ baseOptions?.onError?.(error, variables, context);
+ customMutationOptions?.onError?.(error, variables, context);
},
- ...options,
- ...customOptions,
+ ...baseOptions,
+ ...customMutationOptions,
});
};
}
diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts
new file mode 100644
index 00000000..5ec7278c
--- /dev/null
+++ b/frontend/src/lib/api-client.ts
@@ -0,0 +1,243 @@
+import { createClient } from '@/lib/supabase/client';
+import { handleApiError, handleNetworkError, ErrorContext, ApiError } from './error-handler';
+
+const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
+
+export interface ApiClientOptions {
+ showErrors?: boolean;
+ errorContext?: ErrorContext;
+ timeout?: number;
+}
+
+export interface ApiResponse {
+ data?: T;
+ error?: ApiError;
+ success: boolean;
+}
+
+export const apiClient = {
+ async request(
+ url: string,
+ options: RequestInit & ApiClientOptions = {}
+ ): Promise> {
+ const {
+ showErrors = true,
+ errorContext,
+ timeout = 30000,
+ ...fetchOptions
+ } = options;
+
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
+
+ const supabase = createClient();
+ const { data: { session } } = await supabase.auth.getSession();
+
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ ...fetchOptions.headers as Record,
+ };
+
+ if (session?.access_token) {
+ headers['Authorization'] = `Bearer ${session.access_token}`;
+ }
+
+ const response = await fetch(url, {
+ ...fetchOptions,
+ headers,
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ const error: ApiError = new Error(`HTTP ${response.status}: ${response.statusText}`);
+ error.status = response.status;
+ error.response = response;
+
+ try {
+ const errorData = await response.json();
+ error.details = errorData;
+ if (errorData.message) {
+ error.message = errorData.message;
+ }
+ } catch {
+ }
+
+ if (showErrors) {
+ handleApiError(error, errorContext);
+ }
+
+ return {
+ error,
+ success: false,
+ };
+ }
+
+ let data: T;
+ const contentType = response.headers.get('content-type');
+
+ if (contentType?.includes('application/json')) {
+ data = await response.json();
+ } else if (contentType?.includes('text/')) {
+ data = await response.text() as T;
+ } else {
+ data = await response.blob() as T;
+ }
+
+ return {
+ data,
+ success: true,
+ };
+
+ } catch (error: any) {
+ const apiError: ApiError = error instanceof Error ? error : new Error(String(error));
+
+ if (error.name === 'AbortError') {
+ apiError.message = 'Request timeout';
+ apiError.code = 'TIMEOUT';
+ }
+
+ if (showErrors) {
+ handleNetworkError(apiError, errorContext);
+ }
+
+ return {
+ error: apiError,
+ success: false,
+ };
+ }
+ },
+
+ get: async (
+ url: string,
+ options: Omit = {}
+ ): Promise> => {
+ return apiClient.request(url, {
+ ...options,
+ method: 'GET',
+ });
+ },
+
+ post: async (
+ url: string,
+ data?: any,
+ options: Omit = {}
+ ): Promise> => {
+ return apiClient.request(url, {
+ ...options,
+ method: 'POST',
+ body: data ? JSON.stringify(data) : undefined,
+ });
+ },
+
+ put: async (
+ url: string,
+ data?: any,
+ options: Omit = {}
+ ): Promise> => {
+ return apiClient.request(url, {
+ ...options,
+ method: 'PUT',
+ body: data ? JSON.stringify(data) : undefined,
+ });
+ },
+
+ patch: async (
+ url: string,
+ data?: any,
+ options: Omit = {}
+ ): Promise> => {
+ return apiClient.request(url, {
+ ...options,
+ method: 'PATCH',
+ body: data ? JSON.stringify(data) : undefined,
+ });
+ },
+
+ delete: async (
+ url: string,
+ options: Omit = {}
+ ): Promise> => {
+ return apiClient.request(url, {
+ ...options,
+ method: 'DELETE',
+ });
+ },
+
+ upload: async (
+ url: string,
+ formData: FormData,
+ options: Omit = {}
+ ): Promise> => {
+ const { headers, ...restOptions } = options;
+
+ const uploadHeaders = { ...headers as Record };
+ delete uploadHeaders['Content-Type'];
+
+ return apiClient.request(url, {
+ ...restOptions,
+ method: 'POST',
+ body: formData,
+ headers: uploadHeaders,
+ });
+ },
+};
+
+export const supabaseClient = {
+ async execute(
+ queryFn: () => Promise<{ data: T | null; error: any }>,
+ errorContext?: ErrorContext
+ ): Promise> {
+ try {
+ const { data, error } = await queryFn();
+
+ if (error) {
+ const apiError: ApiError = new Error(error.message || 'Database error');
+ apiError.code = error.code;
+ apiError.details = error;
+
+ handleApiError(apiError, errorContext);
+
+ return {
+ error: apiError,
+ success: false,
+ };
+ }
+
+ return {
+ data: data as T,
+ success: true,
+ };
+ } catch (error: any) {
+ const apiError: ApiError = error instanceof Error ? error : new Error(String(error));
+ handleApiError(apiError, errorContext);
+
+ return {
+ error: apiError,
+ success: false,
+ };
+ }
+ },
+};
+
+export const backendApi = {
+ get: (endpoint: string, options?: Omit) =>
+ apiClient.get(`${API_URL}${endpoint}`, options),
+
+ post: (endpoint: string, data?: any, options?: Omit) =>
+ apiClient.post(`${API_URL}${endpoint}`, data, options),
+
+ put: (endpoint: string, data?: any, options?: Omit) =>
+ apiClient.put(`${API_URL}${endpoint}`, data, options),
+
+ patch: (endpoint: string, data?: any, options?: Omit) =>
+ apiClient.patch(`${API_URL}${endpoint}`, data, options),
+
+ delete: (endpoint: string, options?: Omit) =>
+ apiClient.delete(`${API_URL}${endpoint}`, options),
+
+ upload: (endpoint: string, formData: FormData, options?: Omit) =>
+ apiClient.upload(`${API_URL}${endpoint}`, formData, options),
+};
\ No newline at end of file
diff --git a/frontend/src/lib/api-enhanced.ts b/frontend/src/lib/api-enhanced.ts
new file mode 100644
index 00000000..d50c4213
--- /dev/null
+++ b/frontend/src/lib/api-enhanced.ts
@@ -0,0 +1,470 @@
+import { createClient } from '@/lib/supabase/client';
+import { backendApi, supabaseClient } from './api-client';
+import { handleApiSuccess } from './error-handler';
+import {
+ Project,
+ Thread,
+ Message,
+ AgentRun,
+ InitiateAgentResponse,
+ HealthCheckResponse,
+ FileInfo,
+ CreateCheckoutSessionRequest,
+ CreateCheckoutSessionResponse,
+ CreatePortalSessionRequest,
+ SubscriptionStatus,
+ AvailableModelsResponse,
+ BillingStatusResponse,
+ BillingError
+} from './api';
+
+export * from './api';
+
+export const projectsApi = {
+ async getAll(): Promise {
+ const result = await supabaseClient.execute(
+ async () => {
+ const supabase = createClient();
+ const { data: userData, error: userError } = await supabase.auth.getUser();
+
+ if (userError) {
+ return { data: null, error: userError };
+ }
+
+ if (!userData.user) {
+ return { data: [], error: null };
+ }
+
+ 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')) {
+ return { data: [], error: null };
+ }
+ return { data: null, error };
+ }
+
+ 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: '',
+ },
+ }));
+
+ return { data: mappedProjects, error: null };
+ },
+ { operation: 'load projects', resource: 'projects' }
+ );
+
+ return result.data || [];
+ },
+
+ async getById(projectId: string): Promise {
+ const result = await supabaseClient.execute(
+ async () => {
+ const supabase = createClient();
+ const { data, error } = await supabase
+ .from('projects')
+ .select('*')
+ .eq('project_id', projectId)
+ .single();
+
+ if (error) {
+ if (error.code === 'PGRST116') {
+ return { data: null, error: new Error(`Project not found: ${projectId}`) };
+ }
+ return { data: null, error };
+ }
+
+ // Ensure sandbox is active if it exists
+ if (data.sandbox?.id) {
+ backendApi.post(`/project/${projectId}/sandbox/ensure-active`, undefined, {
+ showErrors: false,
+ errorContext: { silent: true }
+ });
+ }
+
+ 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 { data: mappedProject, error: null };
+ },
+ { operation: 'load project', resource: `project ${projectId}` }
+ );
+
+ return result.data || null;
+ },
+
+ async create(projectData: { name: string; description: string }, accountId?: string): Promise {
+ const result = await supabaseClient.execute(
+ async () => {
+ const supabase = createClient();
+
+ if (!accountId) {
+ const { data: userData, error: userError } = await supabase.auth.getUser();
+ if (userError) return { data: null, error: userError };
+ if (!userData.user) return { data: null, error: 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) return { data: null, error };
+
+ const project: 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 { data: project, error: null };
+ },
+ { operation: 'create project', resource: 'project' }
+ );
+
+ if (result.success && result.data) {
+ handleApiSuccess('Project created successfully', `"${result.data.name}" is ready to use`);
+ }
+
+ return result.data || null;
+ },
+
+ async update(projectId: string, data: Partial): Promise {
+ if (!projectId || projectId === '') {
+ throw new Error('Cannot update project: Invalid project ID');
+ }
+
+ const result = await supabaseClient.execute(
+ async () => {
+ const supabase = createClient();
+ const { data: updatedData, error } = await supabase
+ .from('projects')
+ .update(data)
+ .eq('project_id', projectId)
+ .select()
+ .single();
+
+ if (error) return { data: null, error };
+ if (!updatedData) return { data: null, error: new Error('No data returned from update') };
+
+ // Dispatch custom event for project updates
+ 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: 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 { data: project, error: null };
+ },
+ { operation: 'update project', resource: `project ${projectId}` }
+ );
+
+ if (result.success && result.data) {
+ handleApiSuccess('Project updated successfully');
+ }
+
+ return result.data || null;
+ },
+
+ async delete(projectId: string): Promise {
+ const result = await supabaseClient.execute(
+ async () => {
+ const supabase = createClient();
+ const { error } = await supabase
+ .from('projects')
+ .delete()
+ .eq('project_id', projectId);
+
+ return { data: !error, error };
+ },
+ { operation: 'delete project', resource: `project ${projectId}` }
+ );
+
+ if (result.success) {
+ handleApiSuccess('Project deleted successfully');
+ }
+
+ return result.success;
+ },
+};
+
+export const threadsApi = {
+ async getAll(projectId?: string): Promise {
+ const result = await supabaseClient.execute(
+ async () => {
+ const supabase = createClient();
+ const { data: userData, error: userError } = await supabase.auth.getUser();
+
+ if (userError) return { data: null, error: userError };
+ if (!userData.user) return { data: [], error: null };
+
+ let query = supabase.from('threads').select('*').eq('account_id', userData.user.id);
+
+ if (projectId) {
+ query = query.eq('project_id', projectId);
+ }
+
+ const { data, error } = await query;
+ if (error) return { data: null, error };
+
+ 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 { data: mappedThreads, error: null };
+ },
+ { operation: 'load threads', resource: projectId ? `threads for project ${projectId}` : 'threads' }
+ );
+
+ return result.data || [];
+ },
+
+ async getById(threadId: string): Promise {
+ const result = await supabaseClient.execute(
+ async () => {
+ const supabase = createClient();
+ const { data, error } = await supabase
+ .from('threads')
+ .select('*')
+ .eq('thread_id', threadId)
+ .single();
+
+ return { data, error };
+ },
+ { operation: 'load thread', resource: `thread ${threadId}` }
+ );
+
+ return result.data || null;
+ },
+
+ async create(projectId: string): Promise {
+ const result = await supabaseClient.execute(
+ async () => {
+ const supabase = createClient();
+ const { data: { user } } = await supabase.auth.getUser();
+
+ if (!user) {
+ return { data: null, error: 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();
+
+ return { data, error };
+ },
+ { operation: 'create thread', resource: 'thread' }
+ );
+
+ if (result.success && result.data) {
+ handleApiSuccess('New conversation started');
+ }
+
+ return result.data || null;
+ },
+};
+
+export const agentApi = {
+ async start(
+ threadId: string,
+ options?: {
+ model_name?: string;
+ enable_thinking?: boolean;
+ reasoning_effort?: string;
+ stream?: boolean;
+ }
+ ): Promise<{ agent_run_id: string } | null> {
+ const result = await backendApi.post(
+ `/thread/${threadId}/agent/start`,
+ options,
+ {
+ errorContext: { operation: 'start agent', resource: 'AI assistant' },
+ timeout: 60000,
+ }
+ );
+
+ if (result.success && result.data) {
+ handleApiSuccess('AI assistant started', 'Your request is being processed');
+ }
+
+ return result.data || null;
+ },
+
+ async stop(agentRunId: string): Promise {
+ const result = await backendApi.post(
+ `/agent/${agentRunId}/stop`,
+ undefined,
+ {
+ errorContext: { operation: 'stop agent', resource: 'AI assistant' },
+ }
+ );
+
+ if (result.success) {
+ handleApiSuccess('AI assistant stopped');
+ }
+
+ return result.success;
+ },
+
+ async getStatus(agentRunId: string): Promise {
+ const result = await backendApi.get(
+ `/agent/${agentRunId}/status`,
+ {
+ errorContext: { operation: 'get agent status', resource: 'AI assistant status' },
+ showErrors: false,
+ }
+ );
+
+ return result.data || null;
+ },
+
+ async getRuns(threadId: string): Promise {
+ const result = await backendApi.get(
+ `/thread/${threadId}/agent/runs`,
+ {
+ errorContext: { operation: 'load agent runs', resource: 'conversation history' },
+ }
+ );
+
+ return result.data || [];
+ },
+};
+
+export const billingApi = {
+ async getSubscription(): Promise {
+ const result = await backendApi.get(
+ '/billing/subscription',
+ {
+ errorContext: { operation: 'load subscription', resource: 'billing information' },
+ }
+ );
+
+ return result.data || null;
+ },
+
+ async checkStatus(): Promise {
+ const result = await backendApi.get(
+ '/billing/status',
+ {
+ errorContext: { operation: 'check billing status', resource: 'account status' },
+ }
+ );
+
+ return result.data || null;
+ },
+
+ async createCheckoutSession(request: CreateCheckoutSessionRequest): Promise {
+ const result = await backendApi.post(
+ '/billing/create-checkout-session',
+ request,
+ {
+ errorContext: { operation: 'create checkout session', resource: 'billing' },
+ }
+ );
+
+ return result.data || null;
+ },
+
+ async createPortalSession(request: CreatePortalSessionRequest): Promise<{ url: string } | null> {
+ const result = await backendApi.post(
+ '/billing/create-portal-session',
+ request,
+ {
+ errorContext: { operation: 'create portal session', resource: 'billing portal' },
+ }
+ );
+
+ return result.data || null;
+ },
+
+ async getAvailableModels(): Promise {
+ const result = await backendApi.get(
+ '/billing/available-models',
+ {
+ errorContext: { operation: 'load available models', resource: 'AI models' },
+ }
+ );
+
+ return result.data || null;
+ },
+};
+
+export const healthApi = {
+ async check(): Promise {
+ const result = await backendApi.get(
+ '/health',
+ {
+ errorContext: { operation: 'check system health', resource: 'system status' },
+ timeout: 10000,
+ }
+ );
+
+ return result.data || null;
+ },
+};
\ No newline at end of file
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 2ce05384..f5fbac27 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/client';
+import { handleApiError } from './error-handler';
// Get backend URL from environment variables
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
@@ -158,6 +159,7 @@ export const getProjects = async (): Promise => {
return mappedProjects;
} catch (err) {
console.error('Error fetching projects:', err);
+ handleApiError(err, { operation: 'load projects', resource: 'projects' });
// Return empty array for permission errors to avoid crashing the UI
return [];
}
@@ -251,6 +253,7 @@ export const getProject = async (projectId: string): Promise => {
return mappedProject;
} catch (error) {
console.error(`Error fetching project ${projectId}:`, error);
+ handleApiError(error, { operation: 'load project', resource: `project ${projectId}` });
throw error;
}
};
@@ -283,10 +286,12 @@ export const createProject = async (
.select()
.single();
- if (error) throw error;
+ if (error) {
+ handleApiError(error, { operation: 'create project', resource: 'project' });
+ throw error;
+ }
- // Map the database response to our Project type
- return {
+ const project = {
id: data.project_id,
name: data.name,
description: data.description || '',
@@ -294,6 +299,7 @@ export const createProject = async (
created_at: data.created_at,
sandbox: { id: '', pass: '', vnc_preview: '' },
};
+ return project;
};
export const updateProject = async (
@@ -320,11 +326,14 @@ export const updateProject = async (
if (error) {
console.error('Error updating project:', error);
+ handleApiError(error, { operation: 'update project', resource: `project ${projectId}` });
throw error;
}
if (!updatedData) {
- throw new Error('No data returned from update');
+ const noDataError = new Error('No data returned from update');
+ handleApiError(noDataError, { operation: 'update project', resource: `project ${projectId}` });
+ throw noDataError;
}
// Dispatch a custom event to notify components about the project change
@@ -344,7 +353,7 @@ export const updateProject = async (
}
// Return formatted project data - use same mapping as getProject
- return {
+ const project = {
id: updatedData.project_id,
name: updatedData.name,
description: updatedData.description || '',
@@ -357,6 +366,7 @@ export const updateProject = async (
sandbox_url: '',
},
};
+ return project;
};
export const deleteProject = async (projectId: string): Promise => {
@@ -366,7 +376,10 @@ export const deleteProject = async (projectId: string): Promise => {
.delete()
.eq('project_id', projectId);
- if (error) throw error;
+ if (error) {
+ handleApiError(error, { operation: 'delete project', resource: `project ${projectId}` });
+ throw error;
+ }
};
// Thread APIs
@@ -400,6 +413,7 @@ export const getThreads = async (projectId?: string): Promise => {
if (error) {
console.error('[API] Error fetching threads:', error);
+ handleApiError(error, { operation: 'load threads', resource: projectId ? `threads for project ${projectId}` : 'threads' });
throw error;
}
@@ -425,7 +439,10 @@ export const getThread = async (threadId: string): Promise => {
.eq('thread_id', threadId)
.single();
- if (error) throw error;
+ if (error) {
+ handleApiError(error, { operation: 'load thread', resource: `thread ${threadId}` });
+ throw error;
+ }
return data;
};
@@ -450,8 +467,10 @@ export const createThread = async (projectId: string): Promise => {
.select()
.single();
- if (error) throw error;
-
+ if (error) {
+ handleApiError(error, { operation: 'create thread', resource: 'thread' });
+ throw error;
+ }
return data;
};
@@ -477,6 +496,7 @@ export const addUserMessage = async (
if (error) {
console.error('Error adding user message:', error);
+ handleApiError(error, { operation: 'add message', resource: 'message' });
throw new Error(`Error adding message: ${error.message}`);
}
};
@@ -494,6 +514,7 @@ export const getMessages = async (threadId: string): Promise => {
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}`);
}
@@ -584,7 +605,8 @@ export const startAgent = async (
);
}
- return response.json();
+ const result = await response.json();
+ return result;
} catch (error) {
// Rethrow BillingError instances directly
if (error instanceof BillingError) {
@@ -592,18 +614,21 @@ export const startAgent = async (
}
console.error('[API] Failed to start agent:', error);
-
- // Provide clearer error message for network errors
+
+ // Handle different error types with appropriate user messages
if (
error instanceof TypeError &&
error.message.includes('Failed to fetch')
) {
- throw new Error(
+ 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;
}
- // Rethrow other caught errors
+ // For other errors, add context and rethrow
+ handleApiError(error, { operation: 'start agent', resource: 'AI assistant' });
throw error;
}
};
@@ -628,7 +653,9 @@ export const stopAgent = async (agentRunId: string): Promise => {
} = await supabase.auth.getSession();
if (!session?.access_token) {
- throw new Error('No access token available');
+ const authError = new Error('No access token available');
+ handleApiError(authError, { operation: 'stop agent', resource: 'AI assistant' });
+ throw authError;
}
const response = await fetch(`${API_URL}/agent-run/${agentRunId}/stop`, {
@@ -642,7 +669,9 @@ export const stopAgent = async (agentRunId: string): Promise => {
});
if (!response.ok) {
- throw new Error(`Error stopping agent: ${response.statusText}`);
+ const stopError = new Error(`Error stopping agent: ${response.statusText}`);
+ handleApiError(stopError, { operation: 'stop agent', resource: 'AI assistant' });
+ throw stopError;
}
};
@@ -709,6 +738,7 @@ export const getAgentStatus = async (agentRunId: string): Promise => {
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;
}
};
@@ -740,6 +770,7 @@ export const getAgentRuns = async (threadId: string): Promise => {
return data.agent_runs || [];
} catch (error) {
console.error('Failed to get agent runs:', error);
+ handleApiError(error, { operation: 'load agent runs', resource: 'conversation history' });
throw error;
}
};
@@ -1076,9 +1107,11 @@ export const createSandboxFile = async (
);
}
- return response.json();
+ 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;
}
};
@@ -1128,9 +1161,11 @@ export const createSandboxFileJson = async (
);
}
- return response.json();
+ 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;
}
};
@@ -1192,6 +1227,7 @@ export const listSandboxFiles = async (
return data.files || [];
} catch (error) {
console.error('Failed to list sandbox files:', error);
+ handleApiError(error, { operation: 'list files', resource: `directory ${path}` });
throw error;
}
};
@@ -1248,6 +1284,7 @@ export const getSandboxFileContent = async (
}
} catch (error) {
console.error('Failed to get sandbox file content:', error);
+ handleApiError(error, { operation: 'load file content', resource: `file ${path}` });
throw error;
}
};
@@ -1325,6 +1362,7 @@ export const getPublicProjects = async (): Promise => {
return mappedProjects;
} catch (err) {
console.error('Error fetching public projects:', err);
+ handleApiError(err, { operation: 'load public projects', resource: 'public projects' });
return [];
}
};
@@ -1385,7 +1423,8 @@ export const initiateAgent = async (
);
}
- return response.json();
+ const result = await response.json();
+ return result;
} catch (error) {
console.error('[API] Failed to initiate agent:', error);
@@ -1393,11 +1432,13 @@ export const initiateAgent = async (
error instanceof TypeError &&
error.message.includes('Failed to fetch')
) {
- throw new Error(
+ 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;
}
};
@@ -1415,6 +1456,7 @@ export const checkApiHealth = async (): Promise => {
return response.json();
} catch (error) {
console.error('API health check failed:', error);
+ handleApiError(error, { operation: 'check system health', resource: 'system status' });
throw error;
}
};
@@ -1561,6 +1603,7 @@ export const createCheckoutSession = async (
}
} catch (error) {
console.error('Failed to create checkout session:', error);
+ handleApiError(error, { operation: 'create checkout session', resource: 'billing' });
throw error;
}
};
@@ -1604,6 +1647,7 @@ export const createPortalSession = async (
return response.json();
} catch (error) {
console.error('Failed to create portal session:', error);
+ handleApiError(error, { operation: 'create portal session', resource: 'billing portal' });
throw error;
}
};
@@ -1642,6 +1686,7 @@ export const getSubscription = async (): Promise => {
return response.json();
} catch (error) {
console.error('Failed to get subscription:', error);
+ handleApiError(error, { operation: 'load subscription', resource: 'billing information' });
throw error;
}
};
@@ -1679,6 +1724,7 @@ export const getAvailableModels = async (): Promise =>
return response.json();
} catch (error) {
console.error('Failed to get available models:', error);
+ handleApiError(error, { operation: 'load available models', resource: 'AI models' });
throw error;
}
};
@@ -1717,6 +1763,7 @@ export const checkBillingStatus = async (): Promise => {
return response.json();
} catch (error) {
console.error('Failed to check billing status:', error);
+ handleApiError(error, { operation: 'check billing status', resource: 'account status' });
throw error;
}
};
diff --git a/frontend/src/lib/error-handler.ts b/frontend/src/lib/error-handler.ts
new file mode 100644
index 00000000..a2fd7244
--- /dev/null
+++ b/frontend/src/lib/error-handler.ts
@@ -0,0 +1,195 @@
+import { toast } from 'sonner';
+import { BillingError } from './api';
+
+export interface ApiError extends Error {
+ status?: number;
+ code?: string;
+ details?: any;
+ response?: Response;
+}
+
+export interface ErrorContext {
+ operation?: string;
+ resource?: string;
+ silent?: boolean;
+}
+
+const getStatusMessage = (status: number): string => {
+ switch (status) {
+ case 400:
+ return 'Invalid request. Please check your input and try again.';
+ case 401:
+ return 'Authentication required. Please sign in again.';
+ case 403:
+ return 'Access denied. You don\'t have permission to perform this action.';
+ case 404:
+ return 'The requested resource was not found.';
+ case 408:
+ return 'Request timeout. Please try again.';
+ case 409:
+ return 'Conflict detected. The resource may have been modified by another user.';
+ case 422:
+ return 'Invalid data provided. Please check your input.';
+ case 429:
+ return 'Too many requests. Please wait a moment and try again.';
+ case 500:
+ return 'Server error. Our team has been notified.';
+ case 502:
+ return 'Service temporarily unavailable. Please try again in a moment.';
+ case 503:
+ return 'Service maintenance in progress. Please try again later.';
+ case 504:
+ return 'Request timeout. The server took too long to respond.';
+ default:
+ return 'An unexpected error occurred. Please try again.';
+ }
+};
+
+const extractErrorMessage = (error: any): string => {
+ if (error instanceof BillingError) {
+ return error.detail?.message || error.message || 'Billing issue detected';
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ if (error?.response) {
+ const status = error.response.status;
+ return getStatusMessage(status);
+ }
+
+ if (error?.status) {
+ return getStatusMessage(error.status);
+ }
+
+ if (typeof error === 'string') {
+ return error;
+ }
+
+ if (error?.message) {
+ return error.message;
+ }
+
+ if (error?.error) {
+ return typeof error.error === 'string' ? error.error : error.error.message || 'Unknown error';
+ }
+
+ return 'An unexpected error occurred';
+};
+
+const shouldShowError = (error: any, context?: ErrorContext): boolean => {
+ if (context?.silent) {
+ return false;
+ }
+ if (error instanceof BillingError) {
+ return false;
+ }
+
+ if (error?.status === 404 && context?.resource) {
+ return false;
+ }
+
+ return true;
+};
+
+const formatErrorMessage = (message: string, context?: ErrorContext): string => {
+ if (!context?.operation && !context?.resource) {
+ return message;
+ }
+
+ const parts = [];
+
+ if (context.operation) {
+ parts.push(`Failed to ${context.operation}`);
+ }
+
+ if (context.resource) {
+ parts.push(context.resource);
+ }
+
+ const prefix = parts.join(' ');
+
+ if (message.toLowerCase().includes(context.operation?.toLowerCase() || '')) {
+ return message;
+ }
+
+ return `${prefix}: ${message}`;
+};
+
+
+export const handleApiError = (error: any, context?: ErrorContext): void => {
+ console.error('API Error:', error, context);
+
+ if (!shouldShowError(error, context)) {
+ return;
+ }
+
+ const rawMessage = extractErrorMessage(error);
+ const formattedMessage = formatErrorMessage(rawMessage, context);
+
+ if (error?.status >= 500) {
+ toast.error(formattedMessage, {
+ description: 'Our team has been notified and is working on a fix.',
+ duration: 6000,
+ });
+ } else if (error?.status === 401) {
+ toast.error(formattedMessage, {
+ description: 'Please refresh the page and sign in again.',
+ duration: 8000,
+ });
+ } else if (error?.status === 403) {
+ toast.error(formattedMessage, {
+ description: 'Contact support if you believe this is an error.',
+ duration: 6000,
+ });
+ } else if (error?.status === 429) {
+ toast.warning(formattedMessage, {
+ description: 'Please wait a moment before trying again.',
+ duration: 5000,
+ });
+ } else {
+ toast.error(formattedMessage, {
+ duration: 5000,
+ });
+ }
+};
+
+export const handleNetworkError = (error: any, context?: ErrorContext): void => {
+ const isNetworkError =
+ error?.message?.includes('fetch') ||
+ error?.message?.includes('network') ||
+ error?.message?.includes('connection') ||
+ error?.code === 'NETWORK_ERROR' ||
+ !navigator.onLine;
+
+ if (isNetworkError) {
+ toast.error('Connection error', {
+ description: 'Please check your internet connection and try again.',
+ duration: 6000,
+ });
+ } else {
+ handleApiError(error, context);
+ }
+};
+
+export const handleApiSuccess = (message: string, description?: string): void => {
+ toast.success(message, {
+ description,
+ duration: 3000,
+ });
+};
+
+export const handleApiWarning = (message: string, description?: string): void => {
+ toast.warning(message, {
+ description,
+ duration: 4000,
+ });
+};
+
+export const handleApiInfo = (message: string, description?: string): void => {
+ toast.info(message, {
+ description,
+ duration: 3000,
+ });
+};
\ No newline at end of file
diff --git a/frontend/src/providers/react-query-provider.tsx b/frontend/src/providers/react-query-provider.tsx
index 3b7b4c01..24986235 100644
--- a/frontend/src/providers/react-query-provider.tsx
+++ b/frontend/src/providers/react-query-provider.tsx
@@ -7,6 +7,7 @@ import {
QueryClientProvider,
} from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+import { handleApiError } from '@/lib/error-handler';
export function ReactQueryProvider({
children,
@@ -23,6 +24,7 @@ export function ReactQueryProvider({
staleTime: 20 * 1000,
gcTime: 5 * 60 * 1000,
retry: (failureCount, error: any) => {
+ if (error?.status >= 400 && error?.status < 500) return false;
if (error?.status === 404) return false;
return failureCount < 3;
},
@@ -31,7 +33,16 @@ export function ReactQueryProvider({
refetchOnReconnect: 'always',
},
mutations: {
- retry: 1,
+ retry: (failureCount, error: any) => {
+ if (error?.status >= 400 && error?.status < 500) return false;
+ return failureCount < 1;
+ },
+ onError: (error: any, variables: any, context: any) => {
+ handleApiError(error, {
+ operation: 'perform action',
+ silent: false,
+ });
+ },
},
},
}),