feat: enhance error handling for LLM API calls
Add LLM error alert functionality to display specific error messages based on API responses. Introduce new LlmErrorAlertType interface for structured error alerts. Update chat components to manage and display LLM error alerts effectively, improving user feedback during error scenarios.
This commit is contained in:
@@ -19,7 +19,7 @@ import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
||||
import GitCloneButton from './GitCloneButton';
|
||||
import type { ProviderInfo } from '~/types/model';
|
||||
import StarterTemplates from './StarterTemplates';
|
||||
import type { ActionAlert, SupabaseAlert, DeployAlert } from '~/types/actions';
|
||||
import type { ActionAlert, SupabaseAlert, DeployAlert, LlmErrorAlertType } from '~/types/actions';
|
||||
import DeployChatAlert from '~/components/deploy/DeployAlert';
|
||||
import ChatAlert from './ChatAlert';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
@@ -32,6 +32,7 @@ import { StickToBottom, useStickToBottomContext } from '~/lib/hooks';
|
||||
import { ChatBox } from './ChatBox';
|
||||
import type { DesignScheme } from '~/types/design-scheme';
|
||||
import type { ElementInfo } from '~/components/workbench/Inspector';
|
||||
import LlmErrorAlert from './LLMApiAlert';
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 76;
|
||||
|
||||
@@ -69,6 +70,8 @@ interface BaseChatProps {
|
||||
clearSupabaseAlert?: () => void;
|
||||
deployAlert?: DeployAlert;
|
||||
clearDeployAlert?: () => void;
|
||||
llmErrorAlert?: LlmErrorAlertType;
|
||||
clearLlmErrorAlert?: () => void;
|
||||
data?: JSONValue[] | undefined;
|
||||
chatMode?: 'discuss' | 'build';
|
||||
setChatMode?: (mode: 'discuss' | 'build') => void;
|
||||
@@ -114,6 +117,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
clearDeployAlert,
|
||||
supabaseAlert,
|
||||
clearSupabaseAlert,
|
||||
llmErrorAlert,
|
||||
clearLlmErrorAlert,
|
||||
data,
|
||||
chatMode,
|
||||
setChatMode,
|
||||
@@ -416,6 +421,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{llmErrorAlert && <LlmErrorAlert alert={llmErrorAlert} clearAlert={() => clearLlmErrorAlert?.()} />}
|
||||
</div>
|
||||
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
||||
<ChatBox
|
||||
|
||||
@@ -27,6 +27,7 @@ import { defaultDesignScheme, type DesignScheme } from '~/types/design-scheme';
|
||||
import type { ElementInfo } from '~/components/workbench/Inspector';
|
||||
import type { TextUIPart, FileUIPart, Attachment } from '@ai-sdk/ui-utils';
|
||||
import { useMCPStore } from '~/lib/stores/mcp';
|
||||
import type { LlmErrorAlertType } from '~/types/actions';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
@@ -127,12 +128,13 @@ export const ChatImpl = memo(
|
||||
const [designScheme, setDesignScheme] = useState<DesignScheme>(defaultDesignScheme);
|
||||
const actionAlert = useStore(workbenchStore.alert);
|
||||
const deployAlert = useStore(workbenchStore.deployAlert);
|
||||
const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection
|
||||
const supabaseConn = useStore(supabaseConnection);
|
||||
const selectedProject = supabaseConn.stats?.projects?.find(
|
||||
(project) => project.id === supabaseConn.selectedProjectId,
|
||||
);
|
||||
const supabaseAlert = useStore(workbenchStore.supabaseAlert);
|
||||
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
|
||||
const [llmErrorAlert, setLlmErrorAlert] = useState<LlmErrorAlertType | undefined>(undefined);
|
||||
const [model, setModel] = useState(() => {
|
||||
const savedModel = Cookies.get('selectedModel');
|
||||
return savedModel || DEFAULT_MODEL;
|
||||
@@ -183,15 +185,8 @@ export const ChatImpl = memo(
|
||||
},
|
||||
sendExtraMessageFields: true,
|
||||
onError: (e) => {
|
||||
logger.error('Request failed\n\n', e, error);
|
||||
logStore.logError('Chat request failed', e, {
|
||||
component: 'Chat',
|
||||
action: 'request',
|
||||
error: e.message,
|
||||
});
|
||||
toast.error(
|
||||
'There was an error processing your request: ' + (e.message ? e.message : 'No details were returned'),
|
||||
);
|
||||
setFakeLoading(false);
|
||||
handleError(e, 'chat');
|
||||
},
|
||||
onFinish: (message, response) => {
|
||||
const usage = response.usage;
|
||||
@@ -269,6 +264,80 @@ export const ChatImpl = memo(
|
||||
});
|
||||
};
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: any, context: 'chat' | 'template' | 'llmcall' = 'chat') => {
|
||||
logger.error(`${context} request failed`, error);
|
||||
|
||||
stop();
|
||||
setFakeLoading(false);
|
||||
|
||||
let errorInfo = {
|
||||
message: 'An unexpected error occurred',
|
||||
isRetryable: true,
|
||||
statusCode: 500,
|
||||
provider: provider.name,
|
||||
type: 'unknown' as const,
|
||||
retryDelay: 0,
|
||||
};
|
||||
|
||||
if (error.message) {
|
||||
try {
|
||||
const parsed = JSON.parse(error.message);
|
||||
|
||||
if (parsed.error || parsed.message) {
|
||||
errorInfo = { ...errorInfo, ...parsed };
|
||||
} else {
|
||||
errorInfo.message = error.message;
|
||||
}
|
||||
} catch {
|
||||
errorInfo.message = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
let errorType: LlmErrorAlertType['errorType'] = 'unknown';
|
||||
let title = 'Request Failed';
|
||||
|
||||
if (errorInfo.statusCode === 401 || errorInfo.message.toLowerCase().includes('api key')) {
|
||||
errorType = 'authentication';
|
||||
title = 'Authentication Error';
|
||||
} else if (errorInfo.statusCode === 429 || errorInfo.message.toLowerCase().includes('rate limit')) {
|
||||
errorType = 'rate_limit';
|
||||
title = 'Rate Limit Exceeded';
|
||||
} else if (errorInfo.message.toLowerCase().includes('quota')) {
|
||||
errorType = 'quota';
|
||||
title = 'Quota Exceeded';
|
||||
} else if (errorInfo.statusCode >= 500) {
|
||||
errorType = 'network';
|
||||
title = 'Server Error';
|
||||
}
|
||||
|
||||
logStore.logError(`${context} request failed`, error, {
|
||||
component: 'Chat',
|
||||
action: 'request',
|
||||
error: errorInfo.message,
|
||||
context,
|
||||
retryable: errorInfo.isRetryable,
|
||||
errorType,
|
||||
provider: provider.name,
|
||||
});
|
||||
|
||||
// Create API error alert
|
||||
setLlmErrorAlert({
|
||||
type: 'error',
|
||||
title,
|
||||
description: errorInfo.message,
|
||||
provider: provider.name,
|
||||
errorType,
|
||||
});
|
||||
setData([]);
|
||||
},
|
||||
[provider.name, stop],
|
||||
);
|
||||
|
||||
const clearApiErrorAlert = useCallback(() => {
|
||||
setLlmErrorAlert(undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
@@ -617,6 +686,8 @@ export const ChatImpl = memo(
|
||||
clearSupabaseAlert={() => workbenchStore.clearSupabaseAlert()}
|
||||
deployAlert={deployAlert}
|
||||
clearDeployAlert={() => workbenchStore.clearDeployAlert()}
|
||||
llmErrorAlert={llmErrorAlert}
|
||||
clearLlmErrorAlert={clearApiErrorAlert}
|
||||
data={chatData}
|
||||
chatMode={chatMode}
|
||||
setChatMode={setChatMode}
|
||||
|
||||
109
app/components/chat/LLMApiAlert.tsx
Normal file
109
app/components/chat/LLMApiAlert.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import type { LlmErrorAlertType } from '~/types/actions';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
interface Props {
|
||||
alert: LlmErrorAlertType;
|
||||
clearAlert: () => void;
|
||||
}
|
||||
|
||||
export default function LlmErrorAlert({ alert, clearAlert }: Props) {
|
||||
const { title, description, provider, errorType } = alert;
|
||||
|
||||
const getErrorIcon = () => {
|
||||
switch (errorType) {
|
||||
case 'authentication':
|
||||
return 'i-ph:key-duotone';
|
||||
case 'rate_limit':
|
||||
return 'i-ph:clock-duotone';
|
||||
case 'quota':
|
||||
return 'i-ph:warning-circle-duotone';
|
||||
default:
|
||||
return 'i-ph:warning-duotone';
|
||||
}
|
||||
};
|
||||
|
||||
const getErrorMessage = () => {
|
||||
switch (errorType) {
|
||||
case 'authentication':
|
||||
return `Authentication failed with ${provider}. Please check your API key.`;
|
||||
case 'rate_limit':
|
||||
return `Rate limit exceeded for ${provider}. Please wait before retrying.`;
|
||||
case 'quota':
|
||||
return `Quota exceeded for ${provider}. Please check your account limits.`;
|
||||
default:
|
||||
return 'An error occurred while processing your request.';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4 mb-2"
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<motion.div
|
||||
className="flex-shrink-0"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className={`${getErrorIcon()} text-xl text-bolt-elements-button-danger-text`}></div>
|
||||
</motion.div>
|
||||
|
||||
<div className="ml-3 flex-1">
|
||||
<motion.h3
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-sm font-medium text-bolt-elements-textPrimary"
|
||||
>
|
||||
{title}
|
||||
</motion.h3>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="mt-2 text-sm text-bolt-elements-textSecondary"
|
||||
>
|
||||
<p>{getErrorMessage()}</p>
|
||||
|
||||
{description && (
|
||||
<div className="text-xs text-bolt-elements-textSecondary p-2 bg-bolt-elements-background-depth-3 rounded mt-4 mb-4">
|
||||
Error Details: {description}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-4"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={clearAlert}
|
||||
className={classNames(
|
||||
'px-2 py-1.5 rounded-md text-sm font-medium',
|
||||
'bg-bolt-elements-button-secondary-background',
|
||||
'hover:bg-bolt-elements-button-secondary-backgroundHover',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-secondary-background',
|
||||
'text-bolt-elements-button-secondary-text',
|
||||
)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -375,16 +375,34 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
} catch (error: any) {
|
||||
logger.error(error);
|
||||
|
||||
const errorResponse = {
|
||||
error: true,
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
statusCode: error.statusCode || 500,
|
||||
isRetryable: error.isRetryable !== false, // Default to retryable unless explicitly false
|
||||
provider: error.provider || 'unknown',
|
||||
};
|
||||
|
||||
if (error.message?.includes('API key')) {
|
||||
throw new Response('Invalid or missing API key', {
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
...errorResponse,
|
||||
message: 'Invalid or missing API key',
|
||||
statusCode: 401,
|
||||
isRetryable: false,
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
statusText: 'Unauthorized',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw new Response(null, {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
return new Response(JSON.stringify(errorResponse), {
|
||||
status: errorResponse.statusCode,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
statusText: 'Error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,16 +139,34 @@ async function llmCallAction({ context, request }: ActionFunctionArgs) {
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
|
||||
const errorResponse = {
|
||||
error: true,
|
||||
message: error instanceof Error ? error.message : 'An unexpected error occurred',
|
||||
statusCode: (error as any).statusCode || 500,
|
||||
isRetryable: (error as any).isRetryable !== false,
|
||||
provider: (error as any).provider || 'unknown',
|
||||
};
|
||||
|
||||
if (error instanceof Error && error.message?.includes('API key')) {
|
||||
throw new Response('Invalid or missing API key', {
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
...errorResponse,
|
||||
message: 'Invalid or missing API key',
|
||||
statusCode: 401,
|
||||
isRetryable: false,
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
statusText: 'Unauthorized',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw new Response(null, {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
return new Response(JSON.stringify(errorResponse), {
|
||||
status: errorResponse.statusCode,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
statusText: 'Error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,15 @@ export interface DeployAlert {
|
||||
source?: 'vercel' | 'netlify' | 'github';
|
||||
}
|
||||
|
||||
export interface LlmErrorAlertType {
|
||||
type: 'error' | 'warning';
|
||||
title: string;
|
||||
description: string;
|
||||
content?: string;
|
||||
provider?: string;
|
||||
errorType?: 'authentication' | 'rate_limit' | 'quota' | 'network' | 'unknown';
|
||||
}
|
||||
|
||||
export interface FileHistory {
|
||||
originalContent: string;
|
||||
lastModified: number;
|
||||
|
||||
Reference in New Issue
Block a user