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}
|
renderAssistantMessage={renderAssistantMessage}
|
||||||
renderToolResult={renderToolResult}
|
renderToolResult={renderToolResult}
|
||||||
isLoading={!initialLoadCompleted || isLoading}
|
isLoading={!initialLoadCompleted || isLoading}
|
||||||
|
onFileClick={onViewFiles}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{sandboxId && (
|
{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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { FileAttachment } from '../file-attachment';
|
||||||
|
|
||||||
interface AskContent {
|
interface AskContent {
|
||||||
attachments?: string[];
|
attachments?: string[];
|
||||||
|
@ -38,8 +39,18 @@ export function AskToolView({
|
||||||
isSuccess = true,
|
isSuccess = true,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
onFileClick,
|
onFileClick,
|
||||||
|
project,
|
||||||
}: AskToolViewProps) {
|
}: AskToolViewProps) {
|
||||||
const [askData, setAskData] = useState<AskContent>({});
|
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
|
// Extract attachments from assistant content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -113,47 +124,79 @@ export function AskToolView({
|
||||||
<ScrollArea className="h-full w-full">
|
<ScrollArea className="h-full w-full">
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
{askData.attachments && askData.attachments.length > 0 ? (
|
{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">
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
<Paperclip className="h-4 w-4" />
|
<Paperclip className="h-4 w-4" />
|
||||||
Files ({askData.attachments.length})
|
Files ({askData.attachments.length})
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{askData.attachments.map((attachment, index) => {
|
<div className={cn(
|
||||||
const { icon: FileIcon, color, bgColor } = getFileIconAndColor(attachment);
|
"grid gap-3",
|
||||||
const fileName = attachment.split('/').pop() || attachment;
|
askData.attachments.length === 1 ? "grid-cols-1" :
|
||||||
const filePath = attachment.includes('/') ? attachment.substring(0, attachment.lastIndexOf('/')) : '';
|
askData.attachments.length > 4 ? "grid-cols-1 sm:grid-cols-2 md:grid-cols-3" :
|
||||||
|
"grid-cols-1 sm:grid-cols-2"
|
||||||
return (
|
)}>
|
||||||
<button
|
{askData.attachments
|
||||||
key={index}
|
.sort((a, b) => {
|
||||||
onClick={() => handleFileClick(attachment)}
|
const aIsImage = isImageFile(a);
|
||||||
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"
|
const bIsImage = isImageFile(b);
|
||||||
>
|
const aIsPreviewable = isPreviewableFile(a);
|
||||||
<div className="flex-shrink-0">
|
const bIsPreviewable = isPreviewableFile(b);
|
||||||
<div className={cn(
|
|
||||||
"w-20 h-20 rounded-lg bg-gradient-to-br flex items-center justify-center",
|
if (aIsImage && !bIsImage) return -1;
|
||||||
bgColor
|
if (!aIsImage && bIsImage) return 1;
|
||||||
)}>
|
if (aIsPreviewable && !bIsPreviewable) return -1;
|
||||||
<FileIcon className={cn("h-10 w-10", color)} />
|
if (!aIsPreviewable && bIsPreviewable) return 1;
|
||||||
</div>
|
return 0;
|
||||||
</div>
|
})
|
||||||
<div className="flex flex-col items-center gap-2 min-w-0">
|
.map((attachment, index) => {
|
||||||
<p className="text-sm font-medium text-foreground truncate">
|
const isImage = isImageFile(attachment);
|
||||||
{truncateString(fileName, 30)}
|
const isPreviewable = isPreviewableFile(attachment);
|
||||||
</p>
|
const shouldSpanFull = (askData.attachments!.length % 2 === 1 &&
|
||||||
{filePath && (
|
askData.attachments!.length > 1 &&
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
index === askData.attachments!.length - 1);
|
||||||
{filePath}
|
|
||||||
</p>
|
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">
|
style={(shouldSpanFull || isPreviewable) ? { gridColumn: '1 / -1' } : undefined}
|
||||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</button>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{assistantTimestamp && (
|
{assistantTimestamp && (
|
||||||
|
|
|
@ -474,10 +474,27 @@ export function FileOperationToolView({
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</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">
|
<TabsContent value="code" className="flex-1 h-full mt-0 p-0 overflow-hidden">
|
||||||
<ScrollArea className="h-full w-full">
|
<ScrollArea className="h-full w-full min-h-0">
|
||||||
{operation === 'delete' ? (
|
{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="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)}>
|
<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)} />
|
<Trash2 className={cn("h-10 w-10", config.color)} />
|
||||||
|
@ -542,8 +559,25 @@ export function FileOperationToolView({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="preview" className="w-full flex-1 h-full mt-0 p-0 overflow-hidden">
|
<TabsContent value="preview" className="w-full flex-1 h-full mt-0 p-0 overflow-hidden">
|
||||||
<ScrollArea className="h-full w-full">
|
<ScrollArea className="h-full w-full min-h-0">
|
||||||
{operation === 'delete' ? (
|
{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="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)}>
|
<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)} />
|
<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 { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
// Define types for diffing
|
|
||||||
type DiffType = 'unchanged' | 'added' | 'removed';
|
type DiffType = 'unchanged' | 'added' | 'removed';
|
||||||
|
|
||||||
interface LineDiff {
|
interface LineDiff {
|
||||||
|
@ -48,7 +47,6 @@ interface DiffStats {
|
||||||
deletions: number;
|
deletions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component to display unified diff view
|
|
||||||
const UnifiedDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
|
const UnifiedDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
|
||||||
<div className="bg-white dark:bg-zinc-950 font-mono text-sm overflow-x-auto -mt-2">
|
<div className="bg-white dark:bg-zinc-950 font-mono text-sm overflow-x-auto -mt-2">
|
||||||
<table className="w-full border-collapse">
|
<table className="w-full border-collapse">
|
||||||
|
@ -83,7 +81,6 @@ const UnifiedDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Component to display split diff view
|
|
||||||
const SplitDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
|
const SplitDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
|
||||||
<div className="bg-white dark:bg-zinc-950 font-mono text-sm overflow-x-auto -my-2">
|
<div className="bg-white dark:bg-zinc-950 font-mono text-sm overflow-x-auto -my-2">
|
||||||
<table className="w-full border-collapse">
|
<table className="w-full border-collapse">
|
||||||
|
@ -145,26 +142,34 @@ const SplitDiffView: React.FC<{ lineDiff: LineDiff[] }> = ({ lineDiff }) => (
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Loading state component
|
|
||||||
const LoadingState: React.FC<{ filePath: string | null; progress: number }> = ({ filePath, progress }) => (
|
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="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="text-center w-full max-w-sm">
|
||||||
<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">
|
<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-8 w-8 animate-spin text-purple-500 dark:text-purple-400" />
|
<Loader2 className="h-10 w-10 animate-spin text-purple-500 dark:text-purple-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-100 mb-2">
|
<h3 className="text-xl font-semibold mb-4 text-zinc-900 dark:text-zinc-100">
|
||||||
Processing replacement
|
Processing String Replacement
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
<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">
|
||||||
<span className="font-mono text-xs break-all">Replacing text in {filePath || 'file'}</span>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Error state component
|
|
||||||
const ErrorState: React.FC = () => (
|
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="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="text-center w-full max-w-xs">
|
||||||
|
@ -197,18 +202,18 @@ export function StrReplaceToolView({
|
||||||
const { oldStr, newStr } = extractStrReplaceContent(assistantContent);
|
const { oldStr, newStr } = extractStrReplaceContent(assistantContent);
|
||||||
const toolTitle = getToolTitle(name);
|
const toolTitle = getToolTitle(name);
|
||||||
|
|
||||||
// Simulate progress when streaming
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
|
setProgress(0);
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setProgress((prevProgress) => {
|
setProgress((prevProgress) => {
|
||||||
if (prevProgress >= 95) {
|
if (prevProgress >= 95) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
return prevProgress;
|
return prevProgress;
|
||||||
}
|
}
|
||||||
return prevProgress + 5;
|
return prevProgress + Math.random() * 10 + 5;
|
||||||
});
|
});
|
||||||
}, 300);
|
}, 500);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
} else {
|
} else {
|
||||||
setProgress(100);
|
setProgress(100);
|
||||||
|
@ -372,6 +377,13 @@ export function StrReplaceToolView({
|
||||||
{isSuccess ? 'Replacement completed' : 'Replacement failed'}
|
{isSuccess ? 'Replacement completed' : 'Replacement failed'}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
|
|
@ -178,7 +178,7 @@ export function WebSearchToolView({
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
||||||
<span className="font-mono text-xs break-all">{query}</span>
|
<span className="font-mono text-xs break-all">{query}</span>
|
||||||
</p>
|
</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>
|
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-2">{progress}%</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { initiateAgent, InitiateAgentResponse } from "@/lib/api";
|
import { initiateAgent, InitiateAgentResponse } from "@/lib/api";
|
||||||
import { createMutationHook } from "@/hooks/use-query";
|
import { createMutationHook } from "@/hooks/use-query";
|
||||||
import { toast } from "sonner";
|
import { handleApiSuccess, handleApiError } from "@/lib/error-handler";
|
||||||
import { dashboardKeys } from "./keys";
|
import { dashboardKeys } from "./keys";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useModal } from "@/hooks/use-modal-store";
|
import { useModal } from "@/hooks/use-modal-store";
|
||||||
|
@ -14,25 +14,15 @@ export const useInitiateAgentMutation = createMutationHook<
|
||||||
>(
|
>(
|
||||||
initiateAgent,
|
initiateAgent,
|
||||||
{
|
{
|
||||||
|
errorContext: { operation: 'initiate agent', resource: 'AI assistant' },
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success("Agent initiated successfully");
|
handleApiSuccess("Agent initiated successfully", "Your AI assistant is ready to help");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error && error.message.toLowerCase().includes("payment required")) {
|
||||||
const errorMessage = error.message;
|
return;
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
handleApiError(error, { operation: 'initiate agent', resource: 'AI assistant' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
UseMutationOptions,
|
UseMutationOptions,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
} from '@tanstack/react-query';
|
} from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { handleApiError, ErrorContext } from '@/lib/error-handler';
|
||||||
|
|
||||||
type QueryKeyValue = readonly unknown[];
|
type QueryKeyValue = readonly unknown[];
|
||||||
type QueryKeyFunction = (...args: any[]) => QueryKeyValue;
|
type QueryKeyFunction = (...args: any[]) => QueryKeyValue;
|
||||||
|
@ -54,25 +54,33 @@ export function createMutationHook<
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
UseMutationOptions<TData, TError, TVariables, TContext>,
|
UseMutationOptions<TData, TError, TVariables, TContext>,
|
||||||
'mutationFn'
|
'mutationFn'
|
||||||
>,
|
> & {
|
||||||
|
errorContext?: ErrorContext;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
customOptions?: Omit<
|
customOptions?: Omit<
|
||||||
UseMutationOptions<TData, TError, TVariables, TContext>,
|
UseMutationOptions<TData, TError, TVariables, TContext>,
|
||||||
'mutationFn'
|
'mutationFn'
|
||||||
>,
|
> & {
|
||||||
|
errorContext?: ErrorContext;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
|
const { errorContext: baseErrorContext, ...baseOptions } = options || {};
|
||||||
|
const { errorContext: customErrorContext, ...customMutationOptions } = customOptions || {};
|
||||||
|
|
||||||
return useMutation<TData, TError, TVariables, TContext>({
|
return useMutation<TData, TError, TVariables, TContext>({
|
||||||
mutationFn,
|
mutationFn,
|
||||||
onError: (error, variables, context) => {
|
onError: (error, variables, context) => {
|
||||||
toast.error(
|
const errorContext = customErrorContext || baseErrorContext;
|
||||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`,
|
if (!customMutationOptions?.onError && !baseOptions?.onError) {
|
||||||
);
|
handleApiError(error, errorContext);
|
||||||
options?.onError?.(error, variables, context);
|
}
|
||||||
customOptions?.onError?.(error, variables, context);
|
baseOptions?.onError?.(error, variables, context);
|
||||||
|
customMutationOptions?.onError?.(error, variables, context);
|
||||||
},
|
},
|
||||||
...options,
|
...baseOptions,
|
||||||
...customOptions,
|
...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 { createClient } from '@/lib/supabase/client';
|
||||||
|
import { handleApiError } from './error-handler';
|
||||||
|
|
||||||
// Get backend URL from environment variables
|
// Get backend URL from environment variables
|
||||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||||
|
@ -158,6 +159,7 @@ export const getProjects = async (): Promise<Project[]> => {
|
||||||
return mappedProjects;
|
return mappedProjects;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching projects:', 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 empty array for permission errors to avoid crashing the UI
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
@ -251,6 +253,7 @@ export const getProject = async (projectId: string): Promise<Project> => {
|
||||||
return mappedProject;
|
return mappedProject;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching project ${projectId}:`, error);
|
console.error(`Error fetching project ${projectId}:`, error);
|
||||||
|
handleApiError(error, { operation: 'load project', resource: `project ${projectId}` });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -283,10 +286,12 @@ export const createProject = async (
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
handleApiError(error, { operation: 'create project', resource: 'project' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Map the database response to our Project type
|
const project = {
|
||||||
return {
|
|
||||||
id: data.project_id,
|
id: data.project_id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
|
@ -294,6 +299,7 @@ export const createProject = async (
|
||||||
created_at: data.created_at,
|
created_at: data.created_at,
|
||||||
sandbox: { id: '', pass: '', vnc_preview: '' },
|
sandbox: { id: '', pass: '', vnc_preview: '' },
|
||||||
};
|
};
|
||||||
|
return project;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProject = async (
|
export const updateProject = async (
|
||||||
|
@ -320,11 +326,14 @@ export const updateProject = async (
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error updating project:', error);
|
console.error('Error updating project:', error);
|
||||||
|
handleApiError(error, { operation: 'update project', resource: `project ${projectId}` });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updatedData) {
|
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
|
// 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 formatted project data - use same mapping as getProject
|
||||||
return {
|
const project = {
|
||||||
id: updatedData.project_id,
|
id: updatedData.project_id,
|
||||||
name: updatedData.name,
|
name: updatedData.name,
|
||||||
description: updatedData.description || '',
|
description: updatedData.description || '',
|
||||||
|
@ -357,6 +366,7 @@ export const updateProject = async (
|
||||||
sandbox_url: '',
|
sandbox_url: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return project;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteProject = async (projectId: string): Promise<void> => {
|
export const deleteProject = async (projectId: string): Promise<void> => {
|
||||||
|
@ -366,7 +376,10 @@ export const deleteProject = async (projectId: string): Promise<void> => {
|
||||||
.delete()
|
.delete()
|
||||||
.eq('project_id', projectId);
|
.eq('project_id', projectId);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
handleApiError(error, { operation: 'delete project', resource: `project ${projectId}` });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Thread APIs
|
// Thread APIs
|
||||||
|
@ -400,6 +413,7 @@ export const getThreads = async (projectId?: string): Promise<Thread[]> => {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('[API] Error fetching threads:', error);
|
console.error('[API] Error fetching threads:', error);
|
||||||
|
handleApiError(error, { operation: 'load threads', resource: projectId ? `threads for project ${projectId}` : 'threads' });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -425,7 +439,10 @@ export const getThread = async (threadId: string): Promise<Thread> => {
|
||||||
.eq('thread_id', threadId)
|
.eq('thread_id', threadId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
handleApiError(error, { operation: 'load thread', resource: `thread ${threadId}` });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
@ -450,8 +467,10 @@ export const createThread = async (projectId: string): Promise<Thread> => {
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
handleApiError(error, { operation: 'create thread', resource: 'thread' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -477,6 +496,7 @@ export const addUserMessage = async (
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error adding user message:', error);
|
console.error('Error adding user message:', error);
|
||||||
|
handleApiError(error, { operation: 'add message', resource: 'message' });
|
||||||
throw new Error(`Error adding message: ${error.message}`);
|
throw new Error(`Error adding message: ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -494,6 +514,7 @@ export const getMessages = async (threadId: string): Promise<Message[]> => {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error fetching messages:', 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}`);
|
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) {
|
} catch (error) {
|
||||||
// Rethrow BillingError instances directly
|
// Rethrow BillingError instances directly
|
||||||
if (error instanceof BillingError) {
|
if (error instanceof BillingError) {
|
||||||
|
@ -592,18 +614,21 @@ export const startAgent = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('[API] Failed to start agent:', error);
|
console.error('[API] Failed to start agent:', error);
|
||||||
|
|
||||||
// Provide clearer error message for network errors
|
// Handle different error types with appropriate user messages
|
||||||
if (
|
if (
|
||||||
error instanceof TypeError &&
|
error instanceof TypeError &&
|
||||||
error.message.includes('Failed to fetch')
|
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.`,
|
`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;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -628,7 +653,9 @@ export const stopAgent = async (agentRunId: string): Promise<void> => {
|
||||||
} = await supabase.auth.getSession();
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
if (!session?.access_token) {
|
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`, {
|
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) {
|
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;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] Failed to get agent status:', error);
|
console.error('[API] Failed to get agent status:', error);
|
||||||
|
handleApiError(error, { operation: 'get agent status', resource: 'AI assistant status', silent: true });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -740,6 +770,7 @@ export const getAgentRuns = async (threadId: string): Promise<AgentRun[]> => {
|
||||||
return data.agent_runs || [];
|
return data.agent_runs || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get agent runs:', error);
|
console.error('Failed to get agent runs:', error);
|
||||||
|
handleApiError(error, { operation: 'load agent runs', resource: 'conversation history' });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1076,9 +1107,11 @@ export const createSandboxFile = async (
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create sandbox file:', error);
|
console.error('Failed to create sandbox file:', error);
|
||||||
|
handleApiError(error, { operation: 'create file', resource: `file ${filePath}` });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1128,9 +1161,11 @@ export const createSandboxFileJson = async (
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create sandbox file with JSON:', error);
|
console.error('Failed to create sandbox file with JSON:', error);
|
||||||
|
handleApiError(error, { operation: 'create file', resource: `file ${filePath}` });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1192,6 +1227,7 @@ export const listSandboxFiles = async (
|
||||||
return data.files || [];
|
return data.files || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to list sandbox files:', error);
|
console.error('Failed to list sandbox files:', error);
|
||||||
|
handleApiError(error, { operation: 'list files', resource: `directory ${path}` });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1248,6 +1284,7 @@ export const getSandboxFileContent = async (
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get sandbox file content:', error);
|
console.error('Failed to get sandbox file content:', error);
|
||||||
|
handleApiError(error, { operation: 'load file content', resource: `file ${path}` });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1325,6 +1362,7 @@ export const getPublicProjects = async (): Promise<Project[]> => {
|
||||||
return mappedProjects;
|
return mappedProjects;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching public projects:', err);
|
console.error('Error fetching public projects:', err);
|
||||||
|
handleApiError(err, { operation: 'load public projects', resource: 'public projects' });
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1385,7 +1423,8 @@ export const initiateAgent = async (
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] Failed to initiate agent:', error);
|
console.error('[API] Failed to initiate agent:', error);
|
||||||
|
|
||||||
|
@ -1393,11 +1432,13 @@ export const initiateAgent = async (
|
||||||
error instanceof TypeError &&
|
error instanceof TypeError &&
|
||||||
error.message.includes('Failed to fetch')
|
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.`,
|
`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;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1415,6 +1456,7 @@ export const checkApiHealth = async (): Promise<HealthCheckResponse> => {
|
||||||
return response.json();
|
return response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API health check failed:', error);
|
console.error('API health check failed:', error);
|
||||||
|
handleApiError(error, { operation: 'check system health', resource: 'system status' });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1561,6 +1603,7 @@ export const createCheckoutSession = async (
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create checkout session:', error);
|
console.error('Failed to create checkout session:', error);
|
||||||
|
handleApiError(error, { operation: 'create checkout session', resource: 'billing' });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1604,6 +1647,7 @@ export const createPortalSession = async (
|
||||||
return response.json();
|
return response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create portal session:', error);
|
console.error('Failed to create portal session:', error);
|
||||||
|
handleApiError(error, { operation: 'create portal session', resource: 'billing portal' });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1642,6 +1686,7 @@ export const getSubscription = async (): Promise<SubscriptionStatus> => {
|
||||||
return response.json();
|
return response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get subscription:', error);
|
console.error('Failed to get subscription:', error);
|
||||||
|
handleApiError(error, { operation: 'load subscription', resource: 'billing information' });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1679,6 +1724,7 @@ export const getAvailableModels = async (): Promise<AvailableModelsResponse> =>
|
||||||
return response.json();
|
return response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get available models:', error);
|
console.error('Failed to get available models:', error);
|
||||||
|
handleApiError(error, { operation: 'load available models', resource: 'AI models' });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1717,6 +1763,7 @@ export const checkBillingStatus = async (): Promise<BillingStatusResponse> => {
|
||||||
return response.json();
|
return response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check billing status:', error);
|
console.error('Failed to check billing status:', error);
|
||||||
|
handleApiError(error, { operation: 'check billing status', resource: 'account status' });
|
||||||
throw error;
|
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,
|
QueryClientProvider,
|
||||||
} from '@tanstack/react-query';
|
} from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
import { handleApiError } from '@/lib/error-handler';
|
||||||
|
|
||||||
export function ReactQueryProvider({
|
export function ReactQueryProvider({
|
||||||
children,
|
children,
|
||||||
|
@ -23,6 +24,7 @@ export function ReactQueryProvider({
|
||||||
staleTime: 20 * 1000,
|
staleTime: 20 * 1000,
|
||||||
gcTime: 5 * 60 * 1000,
|
gcTime: 5 * 60 * 1000,
|
||||||
retry: (failureCount, error: any) => {
|
retry: (failureCount, error: any) => {
|
||||||
|
if (error?.status >= 400 && error?.status < 500) return false;
|
||||||
if (error?.status === 404) return false;
|
if (error?.status === 404) return false;
|
||||||
return failureCount < 3;
|
return failureCount < 3;
|
||||||
},
|
},
|
||||||
|
@ -31,7 +33,16 @@ export function ReactQueryProvider({
|
||||||
refetchOnReconnect: 'always',
|
refetchOnReconnect: 'always',
|
||||||
},
|
},
|
||||||
mutations: {
|
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