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:
DESKTOP-RTLN3BA\$punk 2025-12-19 15:52:53 -08:00
parent 48ea41a8d2
commit 70ca585379
4 changed files with 165 additions and 24 deletions

View file

@ -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>

View file

@ -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>;

View file

@ -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);