diff --git a/frontend/src/app/(dashboard)/projects/[projectId]/thread/_components/ThreadLayout.tsx b/frontend/src/app/(dashboard)/projects/[projectId]/thread/_components/ThreadLayout.tsx index fed343f0..ae61e78d 100644 --- a/frontend/src/app/(dashboard)/projects/[projectId]/thread/_components/ThreadLayout.tsx +++ b/frontend/src/app/(dashboard)/projects/[projectId]/thread/_components/ThreadLayout.tsx @@ -112,6 +112,7 @@ export function ThreadLayout({ renderAssistantMessage={renderAssistantMessage} renderToolResult={renderToolResult} isLoading={!initialLoadCompleted || isLoading} + onFileClick={onViewFiles} /> {sandboxId && ( diff --git a/frontend/src/components/examples/ErrorHandlingDemo.tsx b/frontend/src/components/examples/ErrorHandlingDemo.tsx new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/frontend/src/components/examples/ErrorHandlingDemo.tsx @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/test-error-handling.tsx b/frontend/src/components/test-error-handling.tsx new file mode 100644 index 00000000..8662c4f0 --- /dev/null +++ b/frontend/src/components/test-error-handling.tsx @@ -0,0 +1,52 @@ +'use client'; + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { handleApiError, handleApiSuccess, handleApiWarning } from '@/lib/error-handler'; +import { projectsApi } from '@/lib/api-enhanced'; + +export const TestErrorHandling: React.FC = () => { + const testError = () => { + // Simulate a 404 error + const error = new Error('Resource not found'); + (error as any).status = 404; + handleApiError(error, { operation: 'test operation', resource: 'test data' }); + }; + + const testSuccess = () => { + handleApiSuccess('Test successful!', 'This is a success message'); + }; + + const testWarning = () => { + handleApiWarning('Test warning', 'This is a warning message'); + }; + + const testEnhancedApi = async () => { + try { + // This will trigger error handling automatically + await projectsApi.getById('non-existent-id'); + } catch (error) { + console.log('Error caught:', error); + } + }; + + return ( +
+

Test Error Handling

+
+ + + + +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/thread/tool-views/AskToolView.tsx b/frontend/src/components/thread/tool-views/AskToolView.tsx index ca8fbb00..7c2e1a0c 100644 --- a/frontend/src/components/thread/tool-views/AskToolView.tsx +++ b/frontend/src/components/thread/tool-views/AskToolView.tsx @@ -20,6 +20,7 @@ import { cn, truncateString } from '@/lib/utils'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from "@/components/ui/scroll-area"; +import { FileAttachment } from '../file-attachment'; interface AskContent { attachments?: string[]; @@ -38,8 +39,18 @@ export function AskToolView({ isSuccess = true, isStreaming = false, onFileClick, + project, }: AskToolViewProps) { const [askData, setAskData] = useState({}); + const isImageFile = (filePath: string): boolean => { + const filename = filePath.split('/').pop() || ''; + return filename.match(/\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i) !== null; + }; + + const isPreviewableFile = (filePath: string): boolean => { + const ext = filePath.split('.').pop()?.toLowerCase() || ''; + return ext === 'html' || ext === 'htm' || ext === 'md' || ext === 'markdown' || ext === 'csv' || ext === 'tsv'; + }; // Extract attachments from assistant content useEffect(() => { @@ -113,47 +124,79 @@ export function AskToolView({
{askData.attachments && askData.attachments.length > 0 ? ( -
+
Files ({askData.attachments.length})
-
- {askData.attachments.map((attachment, index) => { - const { icon: FileIcon, color, bgColor } = getFileIconAndColor(attachment); - const fileName = attachment.split('/').pop() || attachment; - const filePath = attachment.includes('/') ? attachment.substring(0, attachment.lastIndexOf('/')) : ''; - - return ( - - ); - })} + ); + })}
{assistantTimestamp && ( diff --git a/frontend/src/components/thread/tool-views/FileOperationToolView.tsx b/frontend/src/components/thread/tool-views/FileOperationToolView.tsx index cee65add..c4c1d609 100644 --- a/frontend/src/components/thread/tool-views/FileOperationToolView.tsx +++ b/frontend/src/components/thread/tool-views/FileOperationToolView.tsx @@ -474,10 +474,27 @@ export function FileOperationToolView({
- + - - {operation === 'delete' ? ( + + {isStreaming && !fileContent ? ( +
+
+ +
+

+ {config.progressMessage} +

+
+ + {processedFilePath || 'Processing file...'} + +
+

+ Please wait while the file is being processed +

+
+ ) : operation === 'delete' ? (
@@ -542,8 +559,25 @@ export function FileOperationToolView({ - - {operation === 'delete' ? ( + + {isStreaming && !fileContent ? ( +
+
+ +
+

+ {config.progressMessage} +

+
+ + {processedFilePath || 'Processing file...'} + +
+

+ Please wait while the file is being processed +

+
+ ) : operation === 'delete' ? (
diff --git a/frontend/src/components/thread/tool-views/StrReplaceToolView.tsx b/frontend/src/components/thread/tool-views/StrReplaceToolView.tsx index 32f32649..be7d5435 100644 --- a/frontend/src/components/thread/tool-views/StrReplaceToolView.tsx +++ b/frontend/src/components/thread/tool-views/StrReplaceToolView.tsx @@ -28,7 +28,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -// Define types for diffing type DiffType = 'unchanged' | 'added' | 'removed'; interface LineDiff { @@ -48,7 +47,6 @@ interface DiffStats { deletions: number; } -// Component to display unified diff view const UnifiedDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
@@ -83,7 +81,6 @@ const UnifiedDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => ( ); -// Component to display split diff view const SplitDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
@@ -145,26 +142,34 @@ const SplitDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => ( ); -// Loading state component const LoadingState: React.FC<{ filePath: string | null; progress: number }> = ({ filePath, progress }) => (
-
-
- +
+
+
-

- 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, + }); + }, }, }, }),