feat: implement page limit exceeded notifications and enhance handling in the notification system

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-02-01 18:02:17 -08:00
parent 4e04b4053a
commit d476e18c54
7 changed files with 291 additions and 29 deletions

View file

@ -56,10 +56,12 @@ async def get_global_new_llm_configs(
"""
try:
global_configs = config.GLOBAL_LLM_CONFIGS
safe_configs = []
# Start with Auto mode as the first option (recommended default)
safe_configs = [
{
# Only include Auto mode if there are actual global configs to route to
# Auto mode requires at least one global config with valid API key
if global_configs and len(global_configs) > 0:
safe_configs.append({
"id": 0,
"name": "Auto (Load Balanced)",
"description": "Automatically routes requests across available LLM providers for optimal performance and rate limit handling. Recommended for most users.",
@ -73,8 +75,7 @@ async def get_global_new_llm_configs(
"citations_enabled": True,
"is_global": True,
"is_auto_mode": True,
}
]
})
# Add individual global configs
for cfg in global_configs:

View file

@ -22,7 +22,7 @@ router = APIRouter(prefix="/notifications", tags=["notifications"])
SYNC_WINDOW_DAYS = 14
# Valid notification types - must match frontend InboxItemTypeEnum
NotificationType = Literal["connector_indexing", "document_processing", "new_mention"]
NotificationType = Literal["connector_indexing", "document_processing", "new_mention", "page_limit_exceeded"]
class NotificationResponse(BaseModel):

View file

@ -861,6 +861,97 @@ class MentionNotificationHandler(BaseNotificationHandler):
raise
class PageLimitNotificationHandler(BaseNotificationHandler):
"""Handler for page limit exceeded notifications."""
def __init__(self):
super().__init__("page_limit_exceeded")
def _generate_operation_id(
self, document_name: str, search_space_id: int
) -> str:
"""
Generate a unique operation ID for a page limit exceeded notification.
Args:
document_name: Name of the document that triggered the limit
search_space_id: Search space ID
Returns:
Unique operation ID string
"""
import hashlib
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f")
# Create a short hash of document name to ensure uniqueness
doc_hash = hashlib.md5(document_name.encode()).hexdigest()[:8]
return f"page_limit_{search_space_id}_{timestamp}_{doc_hash}"
async def notify_page_limit_exceeded(
self,
session: AsyncSession,
user_id: UUID,
document_name: str,
document_type: str,
search_space_id: int,
pages_used: int,
pages_limit: int,
pages_to_add: int,
) -> Notification:
"""
Create notification when a document exceeds the user's page limit.
Args:
session: Database session
user_id: User ID
document_name: Name of the document that triggered the limit
document_type: Type of document (FILE, YOUTUBE_VIDEO, etc.)
search_space_id: Search space ID
pages_used: Current number of pages used
pages_limit: User's page limit
pages_to_add: Number of pages the document would add
Returns:
Notification: The created notification
"""
operation_id = self._generate_operation_id(document_name, search_space_id)
# Truncate document name for title if too long
display_name = document_name[:40] + "..." if len(document_name) > 40 else document_name
title = f"Page limit exceeded: {display_name}"
message = f"This document has ~{pages_to_add} page(s) but you've used {pages_used}/{pages_limit} pages. Upgrade to process more documents."
metadata = {
"operation_id": operation_id,
"document_name": document_name,
"document_type": document_type,
"pages_used": pages_used,
"pages_limit": pages_limit,
"pages_to_add": pages_to_add,
"status": "failed",
"error_type": "page_limit_exceeded",
# Navigation target for frontend
"action_url": f"/dashboard/{search_space_id}/more-pages",
"action_label": "Upgrade Plan",
}
notification = Notification(
user_id=user_id,
search_space_id=search_space_id,
type=self.notification_type,
title=title,
message=message,
notification_metadata=metadata,
)
session.add(notification)
await session.commit()
await session.refresh(notification)
logger.info(
f"Created page_limit_exceeded notification {notification.id} for user {user_id}"
)
return notification
class NotificationService:
"""Service for creating and managing notifications that sync via Electric SQL."""
@ -868,6 +959,7 @@ class NotificationService:
connector_indexing = ConnectorIndexingNotificationHandler()
document_processing = DocumentProcessingNotificationHandler()
mention = MentionNotificationHandler()
page_limit = PageLimitNotificationHandler()
@staticmethod
async def create_notification(

View file

@ -414,29 +414,80 @@ async def _process_file_upload(
from app.services.page_limit_service import PageLimitExceededError
# For page limit errors, use the detailed message from the exception
# Check if this is a page limit error (either direct or wrapped in HTTPException)
page_limit_error: PageLimitExceededError | None = None
if isinstance(e, PageLimitExceededError):
error_message = str(e)
page_limit_error = e
elif isinstance(e, HTTPException) and e.__cause__ and isinstance(e.__cause__, PageLimitExceededError):
# HTTPException wraps the original PageLimitExceededError
page_limit_error = e.__cause__
elif isinstance(e, HTTPException) and "page limit" in str(e.detail).lower():
# Fallback: HTTPException with page limit message but no cause
page_limit_error = None # We don't have the details
# For page limit errors, create a dedicated page_limit_exceeded notification
if page_limit_error is not None:
error_message = str(page_limit_error)
# Create a dedicated page limit exceeded notification
try:
# First, mark the processing notification as failed
await session.refresh(notification)
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message="Page limit exceeded",
)
)
# Then create a separate page_limit_exceeded notification for better UX
await NotificationService.page_limit.notify_page_limit_exceeded(
session=session,
user_id=UUID(user_id),
document_name=filename,
document_type="FILE",
search_space_id=search_space_id,
pages_used=page_limit_error.pages_used,
pages_limit=page_limit_error.pages_limit,
pages_to_add=page_limit_error.pages_to_add,
)
except Exception as notif_error:
logger.error(
f"Failed to create page limit notification: {notif_error!s}"
)
elif isinstance(e, HTTPException) and "page limit" in str(e.detail).lower():
# HTTPException with page limit message but no detailed cause
error_message = str(e.detail)
try:
await session.refresh(notification)
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message=error_message,
)
)
except Exception as notif_error:
logger.error(
f"Failed to update notification on failure: {notif_error!s}"
)
else:
error_message = str(e)[:100]
# Update notification on failure - wrapped in try-except to ensure it doesn't fail silently
try:
# Refresh notification to ensure it's not stale after any rollback
await session.refresh(notification)
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message=error_message,
# Update notification on failure - wrapped in try-except to ensure it doesn't fail silently
try:
# Refresh notification to ensure it's not stale after any rollback
await session.refresh(notification)
await (
NotificationService.document_processing.notify_processing_completed(
session=session,
notification=notification,
error_message=error_message,
)
)
except Exception as notif_error:
logger.error(
f"Failed to update notification on failure: {notif_error!s}"
)
)
except Exception as notif_error:
logger.error(
f"Failed to update notification on failure: {notif_error!s}"
)
await task_logger.log_task_failure(
log_entry,

View file

@ -2,11 +2,11 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
import { AlertTriangle, Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
@ -21,6 +21,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
@ -136,6 +137,54 @@ export function LayoutDataProvider({
// Combined unread count for nav badge (mentions take priority for visibility)
const totalUnreadCount = mentionUnreadCount + statusUnreadCount;
// Track seen notification IDs to detect new page_limit_exceeded notifications
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
const isInitialLoad = useRef(true);
// Effect to show toast for new page_limit_exceeded notifications
useEffect(() => {
if (statusLoading) return;
// Get page_limit_exceeded notifications
const pageLimitNotifications = statusItems.filter(
(item) => item.type === "page_limit_exceeded"
);
// On initial load, just mark all as seen without showing toasts
if (isInitialLoad.current) {
for (const notification of pageLimitNotifications) {
seenPageLimitNotifications.current.add(notification.id);
}
isInitialLoad.current = false;
return;
}
// Find new notifications (not yet seen)
const newNotifications = pageLimitNotifications.filter(
(notification) => !seenPageLimitNotifications.current.has(notification.id)
);
// Show toast for each new page_limit_exceeded notification
for (const notification of newNotifications) {
seenPageLimitNotifications.current.add(notification.id);
// Extract metadata for navigation
const actionUrl = isPageLimitExceededMetadata(notification.metadata)
? notification.metadata.action_url
: `/dashboard/${searchSpaceId}/more-pages`;
toast.error(notification.title, {
description: notification.message,
duration: 8000,
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
action: {
label: "View Plans",
onClick: () => router.push(actionUrl),
},
});
}
}, [statusItems, statusLoading, searchSpaceId, router]);
// Unified mark as read that delegates to the correct hook
const markAsRead = useCallback(
async (id: number) => {

View file

@ -3,6 +3,7 @@
import { useAtom } from "jotai";
import {
AlertCircle,
AlertTriangle,
AtSign,
BellDot,
Check,
@ -44,7 +45,11 @@ import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { isConnectorIndexingMetadata, isNewMentionMetadata } from "@/contracts/types/inbox.types";
import {
isConnectorIndexingMetadata,
isNewMentionMetadata,
isPageLimitExceededMetadata,
} from "@/contracts/types/inbox.types";
import type { InboxItem } from "@/hooks/use-inbox";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
@ -232,12 +237,15 @@ export function InboxSidebar({
const currentDataSource = activeTab === "mentions" ? mentions : status;
const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource;
// Status tab includes: connector indexing, document processing
// Status tab includes: connector indexing, document processing, page limit exceeded
// Filter to only show status notification types
const statusItems = useMemo(
() =>
status.items.filter(
(item) => item.type === "connector_indexing" || item.type === "document_processing"
(item) =>
item.type === "connector_indexing" ||
item.type === "document_processing" ||
item.type === "page_limit_exceeded"
),
[status.items]
);
@ -359,6 +367,16 @@ export function InboxSidebar({
router.push(url);
}
}
} else if (item.type === "page_limit_exceeded") {
// Navigate to the upgrade/more-pages page
if (isPageLimitExceededMetadata(item.metadata)) {
const actionUrl = item.metadata.action_url;
if (actionUrl) {
onOpenChange(false);
onCloseMobileSidebar?.();
router.push(actionUrl);
}
}
}
},
[markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId]
@ -419,6 +437,15 @@ export function InboxSidebar({
);
}
// For page limit exceeded, show a warning icon with amber/orange color
if (item.type === "page_limit_exceeded") {
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
<AlertTriangle className="h-4 w-4 text-amber-500" />
</div>
);
}
// For status items (connector/document), show status icons
// Safely access status from metadata
const metadata = item.metadata as Record<string, unknown>;

View file

@ -9,6 +9,7 @@ export const inboxItemTypeEnum = z.enum([
"connector_indexing",
"document_processing",
"new_mention",
"page_limit_exceeded",
]);
/**
@ -88,6 +89,21 @@ export const newMentionMetadata = z.object({
content_preview: z.string(),
});
/**
* Page limit exceeded metadata schema
*/
export const pageLimitExceededMetadata = baseInboxItemMetadata.extend({
document_name: z.string(),
document_type: z.string(),
pages_used: z.number(),
pages_limit: z.number(),
pages_to_add: z.number(),
error_type: z.literal("page_limit_exceeded"),
// Navigation target for frontend
action_url: z.string(),
action_label: z.string(),
});
/**
* Union of all inbox item metadata types
* Use this when the inbox item type is unknown
@ -96,6 +112,7 @@ export const inboxItemMetadata = z.union([
connectorIndexingMetadata,
documentProcessingMetadata,
newMentionMetadata,
pageLimitExceededMetadata,
baseInboxItemMetadata,
]);
@ -133,6 +150,11 @@ export const newMentionInboxItem = inboxItem.extend({
metadata: newMentionMetadata,
});
export const pageLimitExceededInboxItem = inboxItem.extend({
type: z.literal("page_limit_exceeded"),
metadata: pageLimitExceededMetadata,
});
// =============================================================================
// API Request/Response Schemas
// =============================================================================
@ -229,13 +251,27 @@ export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionM
return newMentionMetadata.safeParse(metadata).success;
}
/**
* Type guard for PageLimitExceededMetadata
*/
export function isPageLimitExceededMetadata(
metadata: unknown
): metadata is PageLimitExceededMetadata {
return pageLimitExceededMetadata.safeParse(metadata).success;
}
/**
* Safe metadata parser - returns typed metadata or null
*/
export function parseInboxItemMetadata(
type: InboxItemTypeEnum,
metadata: unknown
): ConnectorIndexingMetadata | DocumentProcessingMetadata | NewMentionMetadata | null {
):
| ConnectorIndexingMetadata
| DocumentProcessingMetadata
| NewMentionMetadata
| PageLimitExceededMetadata
| null {
switch (type) {
case "connector_indexing": {
const result = connectorIndexingMetadata.safeParse(metadata);
@ -249,6 +285,10 @@ export function parseInboxItemMetadata(
const result = newMentionMetadata.safeParse(metadata);
return result.success ? result.data : null;
}
case "page_limit_exceeded": {
const result = pageLimitExceededMetadata.safeParse(metadata);
return result.success ? result.data : null;
}
default:
return null;
}
@ -265,11 +305,13 @@ export type BaseInboxItemMetadata = z.infer<typeof baseInboxItemMetadata>;
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
export type PageLimitExceededMetadata = z.infer<typeof pageLimitExceededMetadata>;
export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>;
export type InboxItem = z.infer<typeof inboxItem>;
export type ConnectorIndexingInboxItem = z.infer<typeof connectorIndexingInboxItem>;
export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;
export type PageLimitExceededInboxItem = z.infer<typeof pageLimitExceededInboxItem>;
// API Request/Response types
export type GetNotificationsRequest = z.infer<typeof getNotificationsRequest>;