diff --git a/surfsense_backend/app/routes/new_llm_config_routes.py b/surfsense_backend/app/routes/new_llm_config_routes.py index 150dfa9f1..b86c37199 100644 --- a/surfsense_backend/app/routes/new_llm_config_routes.py +++ b/surfsense_backend/app/routes/new_llm_config_routes.py @@ -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: diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index 84591f001..83af87e2d 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -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): diff --git a/surfsense_backend/app/services/notification_service.py b/surfsense_backend/app/services/notification_service.py index e0385b91c..8ca4c2e6f 100644 --- a/surfsense_backend/app/services/notification_service.py +++ b/surfsense_backend/app/services/notification_service.py @@ -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( diff --git a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py index 59b062311..c811140f5 100644 --- a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py @@ -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, diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 537ae09ea..2f71adad9 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -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>(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: , + 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) => { diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 2baeaa138..9f73d1a82 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -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 ( +
+ +
+ ); + } + // For status items (connector/document), show status icons // Safely access status from metadata const metadata = item.metadata as Record; diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts index 0983bbc55..3570f2850 100644 --- a/surfsense_web/contracts/types/inbox.types.ts +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -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; export type ConnectorIndexingMetadata = z.infer; export type DocumentProcessingMetadata = z.infer; export type NewMentionMetadata = z.infer; +export type PageLimitExceededMetadata = z.infer; export type InboxItemMetadata = z.infer; export type InboxItem = z.infer; export type ConnectorIndexingInboxItem = z.infer; export type DocumentProcessingInboxItem = z.infer; export type NewMentionInboxItem = z.infer; +export type PageLimitExceededInboxItem = z.infer; // API Request/Response types export type GetNotificationsRequest = z.infer;