mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: implement page limit exceeded notifications and enhance handling in the notification system
This commit is contained in:
parent
4e04b4053a
commit
d476e18c54
7 changed files with 291 additions and 29 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue