diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/AllRulesDisabledBanner.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/AllRulesDisabledBanner.tsx new file mode 100644 index 0000000000..2e99045fb0 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/assistant/AllRulesDisabledBanner.tsx @@ -0,0 +1,47 @@ +"use client"; + +import Link from "next/link"; +import { SettingsIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ActionCard } from "@/components/ui/card"; +import { useRules } from "@/hooks/useRules"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { prefixPath } from "@/utils/path"; +import { + STEP_KEYS, + getStepNumber, +} from "@/app/(app)/[emailAccountId]/onboarding/steps"; + +export function AllRulesDisabledBanner() { + const { data: rules, isLoading } = useRules(); + const { emailAccountId } = useAccount(); + + if (isLoading || !rules) return null; + + const allRulesDisabled = + rules.length > 0 && rules.every((rule) => !rule.enabled); + + if (!allRulesDisabled) return null; + + return ( + } + title="All rules are disabled" + description="Your AI Assistant isn't processing emails because all rules are disabled. Enable them to get started." + action={ + + } + /> + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx index 447533f7d7..ec4a595623 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx @@ -15,6 +15,7 @@ import { SettingsTab } from "@/app/(app)/[emailAccountId]/assistant/settings/Set import { TabSelect } from "@/components/TabSelect"; import { RulesTab } from "@/app/(app)/[emailAccountId]/assistant/RulesTabNew"; import { AIChatButton } from "@/app/(app)/[emailAccountId]/assistant/AIChatButton"; +import { AllRulesDisabledBanner } from "@/app/(app)/[emailAccountId]/assistant/AllRulesDisabledBanner"; import { PageWrapper } from "@/components/PageWrapper"; import { PageHeader } from "@/components/PageHeader"; import { DismissibleVideoCard } from "@/components/VideoCard"; @@ -104,6 +105,8 @@ export default async function AutomationPage({ + +
; }) { const { emailAccountId } = await props.params; + const searchParams = await props.searchParams; + await checkUserOwnsEmailAccount({ emailAccountId }); const emailAccount = await prisma.emailAccount.findUnique({ @@ -47,10 +50,7 @@ export default async function CleanPage(props: { }); const { unhandledCount } = await getUnhandledCount(emailProvider); - const searchParams = await props.searchParams; - const step = searchParams.step - ? Number.parseInt(searchParams.step) - : CleanStep.INTRO; + const step = Number.parseInt(searchParams.step || "") || CleanStep.INTRO; const renderStepContent = () => { switch (step) { @@ -96,7 +96,16 @@ export default async function CleanPage(props: { return (
- {renderStepContent()} + + Loading... +
+ } + > + {renderStepContent()} +
); diff --git a/apps/web/app/(landing)/components/page.tsx b/apps/web/app/(landing)/components/page.tsx index 44a1f33632..92b063b8e8 100644 --- a/apps/web/app/(landing)/components/page.tsx +++ b/apps/web/app/(landing)/components/page.tsx @@ -18,6 +18,7 @@ import { Button as ShadButton } from "@/components/ui/button"; import { Badge } from "@/components/Badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Table, TableBody, TableRow, TableCell } from "@/components/ui/table"; +import { ActionCard } from "@/components/ui/card"; import { AlertBasic } from "@/components/Alert"; import { Notice } from "@/components/Notice"; import { TestErrorButton } from "@/app/(landing)/components/TestError"; @@ -72,6 +73,28 @@ export default function Components() {
Card
This is a basic card. +
+ } + title="Action Card (Green)" + description="This is the default green variant of the ActionCard component." + action={Click Me} + /> + } + title="Action Card (Blue)" + description="This is the blue variant of the ActionCard component." + action={Click Me} + /> + } + title="Action Card (Destructive)" + description="This is the destructive variant of the ActionCard component." + action={Click Me} + /> +
diff --git a/apps/web/components/ui/alert.tsx b/apps/web/components/ui/alert.tsx index 81812c3e33..9a8d1cfe58 100644 --- a/apps/web/components/ui/alert.tsx +++ b/apps/web/components/ui/alert.tsx @@ -22,6 +22,7 @@ const alertVariants = cva( }, ); +/** @deprecated Use ActionCard from "@/components/ui/card" instead */ const Alert = React.forwardRef< HTMLDivElement, React.HTMLAttributes & VariantProps @@ -35,6 +36,7 @@ const Alert = React.forwardRef< )); Alert.displayName = "Alert"; +/** @deprecated Use ActionCard from "@/components/ui/card" instead */ const AlertTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes @@ -47,6 +49,7 @@ const AlertTitle = React.forwardRef< )); AlertTitle.displayName = "AlertTitle"; +/** @deprecated Use ActionCard from "@/components/ui/card" instead */ const AlertDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes diff --git a/apps/web/components/ui/card.tsx b/apps/web/components/ui/card.tsx index 2b49a578cf..0de67199ed 100644 --- a/apps/web/components/ui/card.tsx +++ b/apps/web/components/ui/card.tsx @@ -106,32 +106,93 @@ const CardGreen = React.forwardRef< )); CardGreen.displayName = "CardGreen"; +const CardBlue = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +CardBlue.displayName = "CardBlue"; + +const CardRed = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +CardRed.displayName = "CardRed"; + const ActionCard = React.forwardRef< HTMLDivElement, React.HTMLAttributes & { icon?: React.ReactNode; title: string; - description: string; + description: string | React.ReactNode; action?: React.ReactNode; + variant?: "green" | "blue" | "destructive"; } ->(({ className, icon, title, description, action, ...props }, ref) => ( - -
-
- {icon && ( -
- {icon} +>( + ( + { + className, + icon, + title, + description, + action, + variant = "green", + ...props + }, + ref, + ) => { + const CardVariant = + variant === "blue" + ? CardBlue + : variant === "destructive" + ? CardRed + : CardGreen; + const iconColor = + variant === "blue" + ? "text-blue-600 dark:text-blue-400" + : variant === "destructive" + ? "text-red-600 dark:text-red-400" + : "text-green-600 dark:text-green-400"; + + return ( + +
+
+ {icon && ( +
+ {icon} +
+ )} +
+

{title}

+
+ {description} +
+
- )} -
-

{title}

-

{description}

+ {action &&
{action}
}
-
- {action &&
{action}
} -
- -)); + + ); + }, +); ActionCard.displayName = "ActionCard"; export { @@ -143,5 +204,7 @@ export { CardContent, CardBasic, CardGreen, + CardBlue, + CardRed, ActionCard, }; diff --git a/apps/web/utils/actions/settings.ts b/apps/web/utils/actions/settings.ts index 8f319f099d..08bfe1f621 100644 --- a/apps/web/utils/actions/settings.ts +++ b/apps/web/utils/actions/settings.ts @@ -57,7 +57,7 @@ export const updateAiSettingsAction = actionClientUser ErrorType.INCORRECT_OPENAI_API_KEY, ErrorType.INVALID_OPENAI_MODEL, ErrorType.OPENAI_API_KEY_DEACTIVATED, - ErrorType.OPENAI_RETRY_ERROR, + ErrorType.AI_QUOTA_ERROR, ErrorType.ANTHROPIC_INSUFFICIENT_BALANCE, ], logger, diff --git a/apps/web/utils/error-messages/index.ts b/apps/web/utils/error-messages/index.ts index a857b18d32..0bb5ffc755 100644 --- a/apps/web/utils/error-messages/index.ts +++ b/apps/web/utils/error-messages/index.ts @@ -113,7 +113,7 @@ export const ErrorType = { INCORRECT_OPENAI_API_KEY: "Incorrect OpenAI API key", INVALID_OPENAI_MODEL: "Invalid OpenAI model", OPENAI_API_KEY_DEACTIVATED: "OpenAI API key deactivated", - OPENAI_RETRY_ERROR: "OpenAI retry error", + AI_QUOTA_ERROR: "AI quota error", ANTHROPIC_INSUFFICIENT_BALANCE: "Anthropic insufficient balance", ACCOUNT_DISCONNECTED: "Account disconnected", }; @@ -137,8 +137,8 @@ const errorTypeConfig: Record< actionUrl: "/settings", actionLabel: "Update API Key", }, - [ErrorType.OPENAI_RETRY_ERROR]: { - label: "API Quota Exceeded", + [ErrorType.AI_QUOTA_ERROR]: { + label: "AI Rate Limited", actionUrl: "/settings", actionLabel: "Update Settings", }, diff --git a/apps/web/utils/error.ts b/apps/web/utils/error.ts index d2c40cb84d..07144ec26b 100644 --- a/apps/web/utils/error.ts +++ b/apps/web/utils/error.ts @@ -130,10 +130,18 @@ export function isAnthropicInsufficientBalanceError( ); } -// Handling OpenAI retry errors on their own because this will be related to the user's own API quota, -// rather than an error on our side (as we default to Anthropic atm). -export function isOpenAIRetryError(error: RetryError): boolean { - return error.message.includes("You exceeded your current quota"); +// Handling AI quota/retry errors. This can be related to the user's own API quota or the system's quota. +export function isAiQuotaExceededError(error: RetryError): boolean { + const message = error.message.toLowerCase(); + const quotaErrorMessages = [ + "exceeded your current quota", + "quota exceeded", + "rate limit reached", + "rate_limit_reached", + "too many requests", + "hit a rate limit", + ]; + return quotaErrorMessages.some((substr) => message.includes(substr)); } export function isAWSThrottlingError(error: unknown): error is Error { @@ -178,7 +186,7 @@ export function isKnownApiError(error: unknown): boolean { isInvalidOpenAIModelError(error) || isOpenAIAPIKeyDeactivatedError(error) || isAnthropicInsufficientBalanceError(error))) || - (RetryError.isInstance(error) && isOpenAIRetryError(error)) + (RetryError.isInstance(error) && isAiQuotaExceededError(error)) ); } @@ -227,11 +235,11 @@ export function checkCommonErrors( }; } - if (RetryError.isInstance(error) && isOpenAIRetryError(error)) { - logger.warn("OpenAI quota exceeded for url", { url }); + if (RetryError.isInstance(error) && isAiQuotaExceededError(error)) { + logger.warn("AI quota exceeded for url", { url }); return { - type: "OpenAI Quota Exceeded", - message: `OpenAI error: ${error.message}`, + type: "AI Quota Exceeded", + message: `AI error: ${error.message}`, code: 429, }; } diff --git a/apps/web/utils/llms/index.ts b/apps/web/utils/llms/index.ts index c963266533..40bbd52966 100644 --- a/apps/web/utils/llms/index.ts +++ b/apps/web/utils/llms/index.ts @@ -29,7 +29,7 @@ import { isIncorrectOpenAIAPIKeyError, isInvalidOpenAIModelError, isOpenAIAPIKeyDeactivatedError, - isOpenAIRetryError, + isAiQuotaExceededError, isServiceUnavailableError, } from "@/utils/error"; import { getModel, type ModelType } from "@/utils/llms/model"; @@ -316,14 +316,14 @@ async function handleError( modelName, }); - if (RetryError.isInstance(error) && isOpenAIRetryError(error)) { + if (RetryError.isInstance(error) && isAiQuotaExceededError(error)) { return await addUserErrorMessageWithNotification({ userId, userEmail, emailAccountId, - errorType: ErrorType.OPENAI_RETRY_ERROR, + errorType: ErrorType.AI_QUOTA_ERROR, errorMessage: - "You have exceeded your OpenAI API quota. Please check your OpenAI account.", + "Your AI provider has rejected requests due to rate limits or quota. Please check your provider account if this persists.", logger, }); }