diff --git a/surfsense_backend/app/routes/chats_routes.py b/surfsense_backend/app/routes/chats_routes.py index 26af68149..6a4894643 100644 --- a/surfsense_backend/app/routes/chats_routes.py +++ b/surfsense_backend/app/routes/chats_routes.py @@ -364,6 +364,109 @@ async def read_chats( ) from None +@router.get("/chats/search", response_model=list[ChatReadWithoutMessages]) +async def search_chats( + title: str, + skip: int = 0, + limit: int = 100, + search_space_id: int | None = None, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Search chats by title substring. + Requires CHATS_READ permission for the search space(s). + + Args: + title: Case-insensitive substring to match against chat titles. Required. + skip: Number of items to skip from the beginning. Default: 0. + limit: Maximum number of items to return. Default: 100. + search_space_id: Filter results to a specific search space. Default: None. + session: Database session (injected). + user: Current authenticated user (injected). + + Returns: + List of chats matching the search query. + + Notes: + - Title matching uses ILIKE (case-insensitive). + - Results are ordered by creation date (most recent first). + """ + # Validate pagination parameters + if skip < 0: + raise HTTPException( + status_code=400, detail="skip must be a non-negative integer" + ) + + if limit <= 0 or limit > 1000: + raise HTTPException(status_code=400, detail="limit must be between 1 and 1000") + + # Validate search_space_id if provided + if search_space_id is not None and search_space_id <= 0: + raise HTTPException( + status_code=400, detail="search_space_id must be a positive integer" + ) + + try: + if search_space_id is not None: + # Check permission for specific search space + await check_permission( + session, + user, + search_space_id, + Permission.CHATS_READ.value, + "You don't have permission to read chats in this search space", + ) + # Select specific fields excluding messages + query = ( + select( + Chat.id, + Chat.type, + Chat.title, + Chat.initial_connectors, + Chat.search_space_id, + Chat.created_at, + Chat.state_version, + ) + .filter(Chat.search_space_id == search_space_id) + .order_by(Chat.created_at.desc()) + ) + else: + # Get chats from all search spaces user has membership in + query = ( + select( + Chat.id, + Chat.type, + Chat.title, + Chat.initial_connectors, + Chat.search_space_id, + Chat.created_at, + Chat.state_version, + ) + .join(SearchSpace) + .join(SearchSpaceMembership) + .filter(SearchSpaceMembership.user_id == user.id) + .order_by(Chat.created_at.desc()) + ) + + # Apply title search filter (case-insensitive) + query = query.filter(Chat.title.ilike(f"%{title}%")) + + result = await session.execute(query.offset(skip).limit(limit)) + return result.all() + except HTTPException: + raise + except OperationalError: + raise HTTPException( + status_code=503, detail="Database operation failed. Please try again later." + ) from None + except Exception: + raise HTTPException( + status_code=500, + detail="An unexpected error occurred while searching chats.", + ) from None + + @router.get("/chats/{chat_id}", response_model=ChatRead) async def read_chat( chat_id: int, diff --git a/surfsense_web/components/sidebar/all-chats-sidebar.tsx b/surfsense_web/components/sidebar/all-chats-sidebar.tsx index 5feb4ce3d..208e1ffaa 100644 --- a/surfsense_web/components/sidebar/all-chats-sidebar.tsx +++ b/surfsense_web/components/sidebar/all-chats-sidebar.tsx @@ -5,7 +5,7 @@ import { format } from "date-fns"; import { Loader2, MessageCircleMore, MoreHorizontal, Search, Trash2, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -42,7 +42,9 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS const [searchQuery, setSearchQuery] = useState(""); const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); - // Fetch all chats + const isSearchMode = !!debouncedSearchQuery.trim(); + + // Fetch all chats (when not searching) const { data: chatsData, error: chatsError, @@ -55,7 +57,24 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS search_space_id: Number(searchSpaceId), }, }), - enabled: !!searchSpaceId && open, + enabled: !!searchSpaceId && open && !isSearchMode, + }); + + // Search chats (when searching) + const { + data: searchData, + error: searchError, + isLoading: isLoadingSearch, + } = useQuery({ + queryKey: ["search-chats", searchSpaceId, debouncedSearchQuery], + queryFn: () => + chatsApiService.searchChats({ + queryParams: { + title: debouncedSearchQuery.trim(), + search_space_id: Number(searchSpaceId), + }, + }), + enabled: !!searchSpaceId && open && isSearchMode, }); // Handle chat navigation @@ -76,6 +95,7 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS toast.success(t("chat_deleted") || "Chat deleted successfully"); // Invalidate queries to refresh the list queryClient.invalidateQueries({ queryKey: ["all-chats", searchSpaceId] }); + queryClient.invalidateQueries({ queryKey: ["search-chats", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["chats"] }); } catch (error) { console.error("Error deleting chat:", error); @@ -92,25 +112,10 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS setSearchQuery(""); }, []); - // Filter and sort chats based on search query (client-side filtering) - const chats = useMemo(() => { - const allChats = chatsData ?? []; - - // Sort chats by created_at (most recent first) - const sortedChats = [...allChats].sort((a, b) => { - const dateA = new Date(a.created_at).getTime(); - const dateB = new Date(b.created_at).getTime(); - return dateB - dateA; // Descending order (most recent first) - }); - - if (!debouncedSearchQuery) { - return sortedChats; - } - const query = debouncedSearchQuery.toLowerCase(); - return sortedChats.filter((chat) => chat.title.toLowerCase().includes(query)); - }, [chatsData, debouncedSearchQuery]); - - const isSearchMode = !!debouncedSearchQuery; + // Determine which data source to use and loading/error states + const chats = isSearchMode ? (searchData ?? []) : (chatsData ?? []); + const isLoading = isSearchMode ? isLoadingSearch : isLoadingChats; + const error = isSearchMode ? searchError : chatsError; return ( @@ -147,11 +152,11 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
- {isLoadingChats ? ( + {isLoading ? (
- ) : chatsError ? ( + ) : error ? (
{t("error_loading_chats") || "Error loading chats"}
diff --git a/surfsense_web/contracts/types/chat.types.ts b/surfsense_web/contracts/types/chat.types.ts index a4e5b8f5b..be523e6e2 100644 --- a/surfsense_web/contracts/types/chat.types.ts +++ b/surfsense_web/contracts/types/chat.types.ts @@ -28,6 +28,14 @@ export const getChatsRequest = z.object({ .nullish(), }); +export const searchChatsRequest = z.object({ + queryParams: paginationQueryParams + .extend({ + title: z.string(), + search_space_id: z.number().or(z.string()).optional(), + }), +}); + export const deleteChatResponse = z.object({ message: z.literal("Chat deleted successfully"), }); @@ -49,6 +57,7 @@ export type ChatSummary = z.infer; export type ChatDetails = z.infer & { messages: Message[] }; export type GetChatDetailsRequest = z.infer; export type GetChatsRequest = z.infer; +export type SearchChatsRequest = z.infer; export type DeleteChatResponse = z.infer; export type DeleteChatRequest = z.infer; export type CreateChatRequest = z.infer; diff --git a/surfsense_web/lib/apis/chats-api.service.ts b/surfsense_web/lib/apis/chats-api.service.ts index 7c53815a4..58cad48b0 100644 --- a/surfsense_web/lib/apis/chats-api.service.ts +++ b/surfsense_web/lib/apis/chats-api.service.ts @@ -11,6 +11,8 @@ import { type GetChatsRequest, getChatDetailsRequest, getChatsRequest, + type SearchChatsRequest, + searchChatsRequest, type UpdateChatRequest, updateChatRequest, } from "@/contracts/types/chat.types"; @@ -59,6 +61,28 @@ class ChatApiService { return baseApiService.get(`/api/v1/chats?${queryParams}`, z.array(chatSummary)); }; + searchChats = async (request: SearchChatsRequest) => { + // Validate the request + const parsedRequest = searchChatsRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + // Transform queries params to be string values + const transformedQueryParams = Object.fromEntries( + Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)]) + ); + + const queryParams = new URLSearchParams(transformedQueryParams).toString(); + + return baseApiService.get(`/api/v1/chats/search?${queryParams}`, z.array(chatSummary)); + }; + deleteChat = async (request: DeleteChatRequest) => { // Validate the request const parsedRequest = deleteChatRequest.safeParse(request);