mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-05 23:42:21 +00:00
feat: add server side chat search functionality
- Implemented a new endpoint for searching chats by title substring in the backend. - Updated the frontend to support chat searching with a debounced input. - Added validation for pagination parameters and search space ID. - Enhanced API service to handle search requests and return results based on user permissions.
This commit is contained in:
parent
48ea41a8d2
commit
70ca585379
4 changed files with 165 additions and 24 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -147,11 +152,11 @@ export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsS
|
|||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
{isLoadingChats ? (
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : chatsError ? (
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-sm text-destructive">
|
||||
{t("error_loading_chats") || "Error loading chats"}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<typeof chatSummary>;
|
|||
export type ChatDetails = z.infer<typeof chatDetails> & { messages: Message[] };
|
||||
export type GetChatDetailsRequest = z.infer<typeof getChatDetailsRequest>;
|
||||
export type GetChatsRequest = z.infer<typeof getChatsRequest>;
|
||||
export type SearchChatsRequest = z.infer<typeof searchChatsRequest>;
|
||||
export type DeleteChatResponse = z.infer<typeof deleteChatResponse>;
|
||||
export type DeleteChatRequest = z.infer<typeof deleteChatRequest>;
|
||||
export type CreateChatRequest = z.infer<typeof createChatRequest>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue