mirror of https://github.com/kortix-ai/suna.git
chore(ui): add error toasts
This commit is contained in:
parent
cf13ca8c1f
commit
af416571df
|
@ -112,6 +112,7 @@ export function ThreadLayout({
|
|||
renderAssistantMessage={renderAssistantMessage}
|
||||
renderToolResult={renderToolResult}
|
||||
isLoading={!initialLoadCompleted || isLoading}
|
||||
onFileClick={onViewFiles}
|
||||
/>
|
||||
|
||||
{sandboxId && (
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
|
|
@ -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 (
|
||||
<div className="p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">Test Error Handling</h3>
|
||||
<div className="space-x-2">
|
||||
<Button onClick={testError} variant="destructive">
|
||||
Test Error Toast
|
||||
</Button>
|
||||
<Button onClick={testSuccess} variant="default">
|
||||
Test Success Toast
|
||||
</Button>
|
||||
<Button onClick={testWarning} variant="secondary">
|
||||
Test Warning Toast
|
||||
</Button>
|
||||
<Button onClick={testEnhancedApi} variant="outline">
|
||||
Test Enhanced API Error
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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<AskContent>({});
|
||||
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({
|
|||
<ScrollArea className="h-full w-full">
|
||||
<div className="p-4 space-y-6">
|
||||
{askData.attachments && askData.attachments.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
Files ({askData.attachments.length})
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{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 (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleFileClick(attachment)}
|
||||
className="flex flex-col items-center justify-center gap-3 p-3 h-[15rem] w-full bg-muted/30 rounded-lg border border-border/50 hover:bg-muted/50 transition-colors group cursor-pointer text-left"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className={cn(
|
||||
"w-20 h-20 rounded-lg bg-gradient-to-br flex items-center justify-center",
|
||||
bgColor
|
||||
)}>
|
||||
<FileIcon className={cn("h-10 w-10", color)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{truncateString(fileName, 30)}
|
||||
</p>
|
||||
{filePath && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{filePath}
|
||||
</p>
|
||||
|
||||
<div className={cn(
|
||||
"grid gap-3",
|
||||
askData.attachments.length === 1 ? "grid-cols-1" :
|
||||
askData.attachments.length > 4 ? "grid-cols-1 sm:grid-cols-2 md:grid-cols-3" :
|
||||
"grid-cols-1 sm:grid-cols-2"
|
||||
)}>
|
||||
{askData.attachments
|
||||
.sort((a, b) => {
|
||||
const aIsImage = isImageFile(a);
|
||||
const bIsImage = isImageFile(b);
|
||||
const aIsPreviewable = isPreviewableFile(a);
|
||||
const bIsPreviewable = isPreviewableFile(b);
|
||||
|
||||
if (aIsImage && !bIsImage) return -1;
|
||||
if (!aIsImage && bIsImage) return 1;
|
||||
if (aIsPreviewable && !bIsPreviewable) return -1;
|
||||
if (!aIsPreviewable && bIsPreviewable) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((attachment, index) => {
|
||||
const isImage = isImageFile(attachment);
|
||||
const isPreviewable = isPreviewableFile(attachment);
|
||||
const shouldSpanFull = (askData.attachments!.length % 2 === 1 &&
|
||||
askData.attachments!.length > 1 &&
|
||||
index === askData.attachments!.length - 1);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"relative group",
|
||||
isImage ? "flex items-center justify-center h-full" : "",
|
||||
isPreviewable ? "w-full" : ""
|
||||
)}
|
||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
style={(shouldSpanFull || isPreviewable) ? { gridColumn: '1 / -1' } : undefined}
|
||||
>
|
||||
<FileAttachment
|
||||
filepath={attachment}
|
||||
onClick={handleFileClick}
|
||||
sandboxId={project?.sandbox?.id}
|
||||
showPreview={true}
|
||||
className={cn(
|
||||
"w-full",
|
||||
isImage ? "h-auto min-h-[54px]" :
|
||||
isPreviewable ? "min-h-[240px] max-h-[400px] overflow-auto" : "h-[54px]"
|
||||
)}
|
||||
customStyle={
|
||||
isImage ? {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
'--attachment-height': shouldSpanFull ? '240px' : '180px'
|
||||
} as React.CSSProperties :
|
||||
isPreviewable ? {
|
||||
gridColumn: '1 / -1'
|
||||
} :
|
||||
shouldSpanFull ? {
|
||||
gridColumn: '1 / -1'
|
||||
} : {
|
||||
width: '100%'
|
||||
}
|
||||
}
|
||||
collapsed={false}
|
||||
project={project}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{assistantTimestamp && (
|
||||
|
|
|
@ -474,10 +474,27 @@ export function FileOperationToolView({
|
|||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0 -my-2 flex-1 overflow-hidden relative">
|
||||
<CardContent className="p-0 -my-2 h-full flex-1 overflow-hidden relative">
|
||||
<TabsContent value="code" className="flex-1 h-full mt-0 p-0 overflow-hidden">
|
||||
<ScrollArea className="h-full w-full">
|
||||
{operation === 'delete' ? (
|
||||
<ScrollArea className="h-full w-full min-h-0">
|
||||
{isStreaming && !fileContent ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] h-full py-12 px-6">
|
||||
<div className={cn("w-20 h-20 rounded-full flex items-center justify-center mb-6", config.bgColor)}>
|
||||
<Loader2 className={cn("h-10 w-10 animate-spin", config.color)} />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-6 text-zinc-900 dark:text-zinc-100">
|
||||
{config.progressMessage}
|
||||
</h3>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 w-full max-w-md text-center mb-4 shadow-sm">
|
||||
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">
|
||||
{processedFilePath || 'Processing file...'}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Please wait while the file is being processed
|
||||
</p>
|
||||
</div>
|
||||
) : operation === 'delete' ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 px-6">
|
||||
<div className={cn("w-20 h-20 rounded-full flex items-center justify-center mb-6", config.bgColor)}>
|
||||
<Trash2 className={cn("h-10 w-10", config.color)} />
|
||||
|
@ -542,8 +559,25 @@ export function FileOperationToolView({
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="preview" className="w-full flex-1 h-full mt-0 p-0 overflow-hidden">
|
||||
<ScrollArea className="h-full w-full">
|
||||
{operation === 'delete' ? (
|
||||
<ScrollArea className="h-full w-full min-h-0">
|
||||
{isStreaming && !fileContent ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] h-full py-12 px-6 ">
|
||||
<div className={cn("w-20 h-20 rounded-full flex items-center justify-center mb-6", config.bgColor)}>
|
||||
<Loader2 className={cn("h-10 w-10 animate-spin", config.color)} />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-6 text-zinc-900 dark:text-zinc-100">
|
||||
{config.progressMessage}
|
||||
</h3>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 w-full max-w-md text-center mb-4 shadow-sm">
|
||||
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">
|
||||
{processedFilePath || 'Processing file...'}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Please wait while the file is being processed
|
||||
</p>
|
||||
</div>
|
||||
) : operation === 'delete' ? (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900">
|
||||
<div className={cn("w-20 h-20 rounded-full flex items-center justify-center mb-6", config.bgColor)}>
|
||||
<Trash2 className={cn("h-10 w-10", config.color)} />
|
||||
|
|
|
@ -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 }) => (
|
||||
<div className="bg-white dark:bg-zinc-950 font-mono text-sm overflow-x-auto -mt-2">
|
||||
<table className="w-full border-collapse">
|
||||
|
@ -83,7 +81,6 @@ const UnifiedDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
// Component to display split diff view
|
||||
const SplitDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
|
||||
<div className="bg-white dark:bg-zinc-950 font-mono text-sm overflow-x-auto -my-2">
|
||||
<table className="w-full border-collapse">
|
||||
|
@ -145,26 +142,34 @@ const SplitDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
// Loading state component
|
||||
const LoadingState: React.FC<{ filePath: string | null; progress: number }> = ({ filePath, progress }) => (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900">
|
||||
<div className="text-center w-full max-w-xs">
|
||||
<div className="w-16 h-16 rounded-full mx-auto mb-6 flex items-center justify-center bg-gradient-to-b from-purple-100 to-purple-50 shadow-inner dark:from-purple-800/40 dark:to-purple-900/60 dark:shadow-purple-950/20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-purple-500 dark:text-purple-400" />
|
||||
<div className="text-center w-full max-w-sm">
|
||||
<div className="w-20 h-20 rounded-full mx-auto mb-6 flex items-center justify-center bg-gradient-to-b from-purple-100 to-purple-50 shadow-inner dark:from-purple-800/40 dark:to-purple-900/60 dark:shadow-purple-950/20">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-purple-500 dark:text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-100 mb-2">
|
||||
Processing replacement
|
||||
<h3 className="text-xl font-semibold mb-4 text-zinc-900 dark:text-zinc-100">
|
||||
Processing String Replacement
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
||||
<span className="font-mono text-xs break-all">Replacing text in {filePath || 'file'}</span>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 w-full text-center mb-6 shadow-sm">
|
||||
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300 break-all">
|
||||
{filePath || 'Processing file...'}
|
||||
</code>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Progress value={Math.min(progress, 100)} className="w-full h-3" />
|
||||
<div className="flex justify-between items-center text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<span>Analyzing text patterns</span>
|
||||
<span className="font-mono">{Math.round(Math.min(progress, 100))}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-4">
|
||||
Please wait while the replacement is being processed
|
||||
</p>
|
||||
<Progress value={progress} className="w-full h-2" />
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-2">{progress}%</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Error state component
|
||||
const ErrorState: React.FC = () => (
|
||||
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900">
|
||||
<div className="text-center w-full max-w-xs">
|
||||
|
@ -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'}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{isStreaming && (
|
||||
<Badge className="bg-gradient-to-b from-blue-200 to-blue-100 text-blue-700 dark:from-blue-800/50 dark:to-blue-900/60 dark:text-blue-300">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
Processing replacement
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
|
|
@ -178,7 +178,7 @@ export function WebSearchToolView({
|
|||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
||||
<span className="font-mono text-xs break-all">{query}</span>
|
||||
</p>
|
||||
<Progress value={progress} className="w-full h-2" />
|
||||
<Progress value={progress} className="w-full h-1" />
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-2">{progress}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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<TData, TError, TVariables, TContext>,
|
||||
'mutationFn'
|
||||
>,
|
||||
> & {
|
||||
errorContext?: ErrorContext;
|
||||
},
|
||||
) {
|
||||
return (
|
||||
customOptions?: Omit<
|
||||
UseMutationOptions<TData, TError, TVariables, TContext>,
|
||||
'mutationFn'
|
||||
>,
|
||||
> & {
|
||||
errorContext?: ErrorContext;
|
||||
},
|
||||
) => {
|
||||
const { errorContext: baseErrorContext, ...baseOptions } = options || {};
|
||||
const { errorContext: customErrorContext, ...customMutationOptions } = customOptions || {};
|
||||
|
||||
return useMutation<TData, TError, TVariables, TContext>({
|
||||
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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<T = any> {
|
||||
data?: T;
|
||||
error?: ApiError;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
async request<T = any>(
|
||||
url: string,
|
||||
options: RequestInit & ApiClientOptions = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
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<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...fetchOptions.headers as Record<string, string>,
|
||||
};
|
||||
|
||||
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 <T = any>(
|
||||
url: string,
|
||||
options: Omit<RequestInit & ApiClientOptions, 'method' | 'body'> = {}
|
||||
): Promise<ApiResponse<T>> => {
|
||||
return apiClient.request<T>(url, {
|
||||
...options,
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
|
||||
post: async <T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
options: Omit<RequestInit & ApiClientOptions, 'method'> = {}
|
||||
): Promise<ApiResponse<T>> => {
|
||||
return apiClient.request<T>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
put: async <T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
options: Omit<RequestInit & ApiClientOptions, 'method'> = {}
|
||||
): Promise<ApiResponse<T>> => {
|
||||
return apiClient.request<T>(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
patch: async <T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
options: Omit<RequestInit & ApiClientOptions, 'method'> = {}
|
||||
): Promise<ApiResponse<T>> => {
|
||||
return apiClient.request<T>(url, {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
delete: async <T = any>(
|
||||
url: string,
|
||||
options: Omit<RequestInit & ApiClientOptions, 'method' | 'body'> = {}
|
||||
): Promise<ApiResponse<T>> => {
|
||||
return apiClient.request<T>(url, {
|
||||
...options,
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
upload: async <T = any>(
|
||||
url: string,
|
||||
formData: FormData,
|
||||
options: Omit<RequestInit & ApiClientOptions, 'method' | 'body'> = {}
|
||||
): Promise<ApiResponse<T>> => {
|
||||
const { headers, ...restOptions } = options;
|
||||
|
||||
const uploadHeaders = { ...headers as Record<string, string> };
|
||||
delete uploadHeaders['Content-Type'];
|
||||
|
||||
return apiClient.request<T>(url, {
|
||||
...restOptions,
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: uploadHeaders,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const supabaseClient = {
|
||||
async execute<T = any>(
|
||||
queryFn: () => Promise<{ data: T | null; error: any }>,
|
||||
errorContext?: ErrorContext
|
||||
): Promise<ApiResponse<T>> {
|
||||
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: <T = any>(endpoint: string, options?: Omit<RequestInit & ApiClientOptions, 'method' | 'body'>) =>
|
||||
apiClient.get<T>(`${API_URL}${endpoint}`, options),
|
||||
|
||||
post: <T = any>(endpoint: string, data?: any, options?: Omit<RequestInit & ApiClientOptions, 'method'>) =>
|
||||
apiClient.post<T>(`${API_URL}${endpoint}`, data, options),
|
||||
|
||||
put: <T = any>(endpoint: string, data?: any, options?: Omit<RequestInit & ApiClientOptions, 'method'>) =>
|
||||
apiClient.put<T>(`${API_URL}${endpoint}`, data, options),
|
||||
|
||||
patch: <T = any>(endpoint: string, data?: any, options?: Omit<RequestInit & ApiClientOptions, 'method'>) =>
|
||||
apiClient.patch<T>(`${API_URL}${endpoint}`, data, options),
|
||||
|
||||
delete: <T = any>(endpoint: string, options?: Omit<RequestInit & ApiClientOptions, 'method' | 'body'>) =>
|
||||
apiClient.delete<T>(`${API_URL}${endpoint}`, options),
|
||||
|
||||
upload: <T = any>(endpoint: string, formData: FormData, options?: Omit<RequestInit & ApiClientOptions, 'method' | 'body'>) =>
|
||||
apiClient.upload<T>(`${API_URL}${endpoint}`, formData, options),
|
||||
};
|
|
@ -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<Project[]> {
|
||||
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<Project | null> {
|
||||
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<Project | null> {
|
||||
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<Project>): Promise<Project | null> {
|
||||
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<boolean> {
|
||||
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<Thread[]> {
|
||||
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<Thread | null> {
|
||||
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<Thread | null> {
|
||||
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<boolean> {
|
||||
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<AgentRun | null> {
|
||||
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<AgentRun[]> {
|
||||
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<SubscriptionStatus | null> {
|
||||
const result = await backendApi.get(
|
||||
'/billing/subscription',
|
||||
{
|
||||
errorContext: { operation: 'load subscription', resource: 'billing information' },
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async checkStatus(): Promise<BillingStatusResponse | null> {
|
||||
const result = await backendApi.get(
|
||||
'/billing/status',
|
||||
{
|
||||
errorContext: { operation: 'check billing status', resource: 'account status' },
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async createCheckoutSession(request: CreateCheckoutSessionRequest): Promise<CreateCheckoutSessionResponse | null> {
|
||||
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<AvailableModelsResponse | null> {
|
||||
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<HealthCheckResponse | null> {
|
||||
const result = await backendApi.get(
|
||||
'/health',
|
||||
{
|
||||
errorContext: { operation: 'check system health', resource: 'system status' },
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
};
|
|
@ -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<Project[]> => {
|
|||
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<Project> => {
|
|||
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<void> => {
|
||||
|
@ -366,7 +376,10 @@ export const deleteProject = async (projectId: string): Promise<void> => {
|
|||
.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<Thread[]> => {
|
|||
|
||||
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<Thread> => {
|
|||
.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<Thread> => {
|
|||
.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<Message[]> => {
|
|||
|
||||
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<void> => {
|
|||
} = 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<void> => {
|
|||
});
|
||||
|
||||
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<AgentRun> => {
|
|||
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<AgentRun[]> => {
|
|||
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<Project[]> => {
|
|||
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<HealthCheckResponse> => {
|
|||
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<SubscriptionStatus> => {
|
|||
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<AvailableModelsResponse> =>
|
|||
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<BillingStatusResponse> => {
|
|||
return response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to check billing status:', error);
|
||||
handleApiError(error, { operation: 'check billing status', resource: 'account status' });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
Loading…
Reference in New Issue