mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +02:00
- Introduced pg_trgm extension and GIN trigram indexes for efficient document title searches, enhancing performance for mention picker functionality. - Implemented a new API endpoint for lightweight document title searches, returning only essential fields. - Updated frontend components to utilize the new title search feature with throttling for improved user experience. - Added necessary schemas and types for the new search functionality.
484 lines
15 KiB
TypeScript
484 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { FileText } from "lucide-react";
|
|
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|
import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types";
|
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface DocumentMentionPickerRef {
|
|
selectHighlighted: () => void;
|
|
moveUp: () => void;
|
|
moveDown: () => void;
|
|
}
|
|
|
|
interface DocumentMentionPickerProps {
|
|
searchSpaceId: number;
|
|
onSelectionChange: (documents: Pick<Document, "id" | "title" | "document_type">[]) => void;
|
|
onDone: () => void;
|
|
initialSelectedDocuments?: Pick<Document, "id" | "title" | "document_type">[];
|
|
externalSearch?: string;
|
|
}
|
|
|
|
const PAGE_SIZE = 20;
|
|
const MIN_SEARCH_LENGTH = 2;
|
|
const THROTTLE_MS = 200;
|
|
|
|
/**
|
|
* Throttle hook - fires immediately, then at most once per interval
|
|
* Better than debounce for typeahead: user sees results updating as they type
|
|
*/
|
|
function useThrottled<T>(value: T, delay = THROTTLE_MS) {
|
|
const [throttled, setThrottled] = useState(value);
|
|
const lastExecuted = useRef(Date.now());
|
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
useEffect(() => {
|
|
const now = Date.now();
|
|
const elapsed = now - lastExecuted.current;
|
|
|
|
if (elapsed >= delay) {
|
|
// Enough time has passed, update immediately
|
|
lastExecuted.current = now;
|
|
setThrottled(value);
|
|
} else {
|
|
// Schedule update for remaining time
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
timeoutRef.current = setTimeout(() => {
|
|
lastExecuted.current = Date.now();
|
|
setThrottled(value);
|
|
}, delay - elapsed);
|
|
}
|
|
|
|
return () => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
};
|
|
}, [value, delay]);
|
|
|
|
return throttled;
|
|
}
|
|
|
|
export const DocumentMentionPicker = forwardRef<
|
|
DocumentMentionPickerRef,
|
|
DocumentMentionPickerProps
|
|
>(function DocumentMentionPicker(
|
|
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
|
|
ref
|
|
) {
|
|
const queryClient = useQueryClient();
|
|
|
|
// Use external search with throttle (not debounce) for responsive feel
|
|
const search = externalSearch;
|
|
const throttledSearch = useThrottled(search, THROTTLE_MS);
|
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// State for pagination
|
|
const [accumulatedDocuments, setAccumulatedDocuments] = useState<
|
|
Pick<Document, "id" | "title" | "document_type">[]
|
|
>([]);
|
|
const [currentPage, setCurrentPage] = useState(0);
|
|
const [hasMore, setHasMore] = useState(false);
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
|
|
// Check if search is long enough
|
|
const isSearchValid = throttledSearch.trim().length >= MIN_SEARCH_LENGTH;
|
|
const shouldSearch = throttledSearch.trim().length > 0;
|
|
|
|
// Prefetch first page when picker mounts - results appear instantly
|
|
useEffect(() => {
|
|
if (!searchSpaceId) return;
|
|
|
|
const prefetchParams = {
|
|
search_space_id: searchSpaceId,
|
|
page: 0,
|
|
page_size: PAGE_SIZE,
|
|
};
|
|
|
|
// Prefetch document titles (user docs)
|
|
queryClient.prefetchQuery({
|
|
queryKey: ["document-titles", prefetchParams],
|
|
queryFn: () => documentsApiService.searchDocumentTitles({ queryParams: prefetchParams }),
|
|
staleTime: 60 * 1000,
|
|
});
|
|
|
|
// Prefetch SurfSense docs
|
|
queryClient.prefetchQuery({
|
|
queryKey: ["surfsense-docs-mention", "", false],
|
|
queryFn: () =>
|
|
documentsApiService.getSurfsenseDocs({
|
|
queryParams: { page: 0, page_size: PAGE_SIZE },
|
|
}),
|
|
staleTime: 3 * 60 * 1000,
|
|
});
|
|
}, [searchSpaceId, queryClient]);
|
|
|
|
// Reset pagination when search or search space changes
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset pagination when search/space changes
|
|
useEffect(() => {
|
|
setAccumulatedDocuments([]);
|
|
setCurrentPage(0);
|
|
setHasMore(false);
|
|
setHighlightedIndex(0);
|
|
}, [throttledSearch, searchSpaceId]);
|
|
|
|
// Query params for lightweight title search
|
|
const titleSearchParams = useMemo(
|
|
() => ({
|
|
search_space_id: searchSpaceId,
|
|
page: 0,
|
|
page_size: PAGE_SIZE,
|
|
...(isSearchValid ? { title: throttledSearch.trim() } : {}),
|
|
}),
|
|
[searchSpaceId, throttledSearch, isSearchValid]
|
|
);
|
|
|
|
const surfsenseDocsQueryParams = useMemo(() => {
|
|
const params: { page: number; page_size: number; title?: string } = {
|
|
page: 0,
|
|
page_size: PAGE_SIZE,
|
|
};
|
|
if (isSearchValid) {
|
|
params.title = throttledSearch.trim();
|
|
}
|
|
return params;
|
|
}, [throttledSearch, isSearchValid]);
|
|
|
|
// Use the new lightweight endpoint for document title search
|
|
const { data: titleSearchResults, isLoading: isTitleSearchLoading } = useQuery({
|
|
queryKey: ["document-titles", titleSearchParams],
|
|
queryFn: () => documentsApiService.searchDocumentTitles({ queryParams: titleSearchParams }),
|
|
staleTime: 60 * 1000, // 1 minute - shorter for fresher results
|
|
enabled: !!searchSpaceId && currentPage === 0 && (!shouldSearch || isSearchValid),
|
|
});
|
|
|
|
// Use query for fetching first page of SurfSense docs
|
|
const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({
|
|
queryKey: ["surfsense-docs-mention", throttledSearch, isSearchValid],
|
|
queryFn: () => documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }),
|
|
staleTime: 3 * 60 * 1000,
|
|
enabled: !shouldSearch || isSearchValid,
|
|
});
|
|
|
|
// Update accumulated documents when first page loads - combine both sources
|
|
useEffect(() => {
|
|
if (currentPage === 0) {
|
|
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
|
|
|
|
// Add SurfSense docs first (they appear at top)
|
|
if (surfsenseDocs?.items) {
|
|
for (const doc of surfsenseDocs.items) {
|
|
combinedDocs.push({
|
|
id: doc.id,
|
|
title: doc.title,
|
|
document_type: "SURFSENSE_DOCS",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add regular documents from lightweight endpoint
|
|
if (titleSearchResults?.items) {
|
|
combinedDocs.push(...titleSearchResults.items);
|
|
setHasMore(titleSearchResults.has_more);
|
|
}
|
|
|
|
setAccumulatedDocuments(combinedDocs);
|
|
}
|
|
}, [titleSearchResults, surfsenseDocs, currentPage]);
|
|
|
|
// Function to load next page using lightweight endpoint
|
|
const loadNextPage = useCallback(async () => {
|
|
if (isLoadingMore || !hasMore) return;
|
|
|
|
const nextPage = currentPage + 1;
|
|
setIsLoadingMore(true);
|
|
|
|
try {
|
|
const queryParams = {
|
|
search_space_id: searchSpaceId,
|
|
page: nextPage,
|
|
page_size: PAGE_SIZE,
|
|
...(isSearchValid ? { title: throttledSearch.trim() } : {}),
|
|
};
|
|
const response: SearchDocumentTitlesResponse =
|
|
await documentsApiService.searchDocumentTitles({ queryParams });
|
|
|
|
setAccumulatedDocuments((prev) => [...prev, ...response.items]);
|
|
setHasMore(response.has_more);
|
|
setCurrentPage(nextPage);
|
|
} catch (error) {
|
|
console.error("Failed to load next page:", error);
|
|
} finally {
|
|
setIsLoadingMore(false);
|
|
}
|
|
}, [currentPage, hasMore, isLoadingMore, throttledSearch, searchSpaceId, isSearchValid]);
|
|
|
|
// Infinite scroll handler
|
|
const handleScroll = useCallback(
|
|
(e: React.UIEvent<HTMLDivElement>) => {
|
|
const target = e.currentTarget;
|
|
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
|
|
|
// Load more when within 50px of bottom
|
|
if (scrollBottom < 50 && hasMore && !isLoadingMore) {
|
|
loadNextPage();
|
|
}
|
|
},
|
|
[hasMore, isLoadingMore, loadNextPage]
|
|
);
|
|
|
|
const actualDocuments = accumulatedDocuments;
|
|
const actualLoading = (isTitleSearchLoading || isSurfsenseDocsLoading) && currentPage === 0;
|
|
|
|
// Show hint when search is too short
|
|
const showSearchHint = shouldSearch && !isSearchValid;
|
|
|
|
// Split documents into SurfSense docs and user docs for grouped rendering
|
|
const surfsenseDocsList = useMemo(
|
|
() => actualDocuments.filter((doc) => doc.document_type === "SURFSENSE_DOCS"),
|
|
[actualDocuments]
|
|
);
|
|
const userDocsList = useMemo(
|
|
() => actualDocuments.filter((doc) => doc.document_type !== "SURFSENSE_DOCS"),
|
|
[actualDocuments]
|
|
);
|
|
|
|
// Track already selected documents using unique key (document_type:id) to avoid ID collisions
|
|
const selectedKeys = useMemo(
|
|
() => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)),
|
|
[initialSelectedDocuments]
|
|
);
|
|
|
|
// Filter out already selected documents for navigation
|
|
const selectableDocuments = useMemo(
|
|
() => actualDocuments.filter((doc) => !selectedKeys.has(`${doc.document_type}:${doc.id}`)),
|
|
[actualDocuments, selectedKeys]
|
|
);
|
|
|
|
const handleSelectDocument = useCallback(
|
|
(doc: Pick<Document, "id" | "title" | "document_type">) => {
|
|
onSelectionChange([...initialSelectedDocuments, doc]);
|
|
onDone();
|
|
},
|
|
[initialSelectedDocuments, onSelectionChange, onDone]
|
|
);
|
|
|
|
// Scroll highlighted item into view
|
|
useEffect(() => {
|
|
const item = itemRefs.current.get(highlightedIndex);
|
|
if (item) {
|
|
item.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
}
|
|
}, [highlightedIndex]);
|
|
|
|
// Reset highlighted index when external search changes
|
|
const prevSearchRef = useRef(search);
|
|
if (prevSearchRef.current !== search) {
|
|
prevSearchRef.current = search;
|
|
if (highlightedIndex !== 0) {
|
|
setHighlightedIndex(0);
|
|
}
|
|
}
|
|
|
|
// Expose methods to parent via ref
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
selectHighlighted: () => {
|
|
if (selectableDocuments[highlightedIndex]) {
|
|
handleSelectDocument(selectableDocuments[highlightedIndex]);
|
|
}
|
|
},
|
|
moveUp: () => {
|
|
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1));
|
|
},
|
|
moveDown: () => {
|
|
setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0));
|
|
},
|
|
}),
|
|
[selectableDocuments, highlightedIndex, handleSelectDocument]
|
|
);
|
|
|
|
// Handle keyboard navigation
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (selectableDocuments.length === 0) return;
|
|
|
|
switch (e.key) {
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0));
|
|
break;
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1));
|
|
break;
|
|
case "Enter":
|
|
e.preventDefault();
|
|
if (selectableDocuments[highlightedIndex]) {
|
|
handleSelectDocument(selectableDocuments[highlightedIndex]);
|
|
}
|
|
break;
|
|
case "Escape":
|
|
e.preventDefault();
|
|
onDone();
|
|
break;
|
|
}
|
|
},
|
|
[selectableDocuments, highlightedIndex, handleSelectDocument, onDone]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-col w-[280px] sm:w-[320px] bg-popover rounded-lg"
|
|
onKeyDown={handleKeyDown}
|
|
role="listbox"
|
|
tabIndex={-1}
|
|
>
|
|
{/* Document List - Shows max 5 items on mobile, 7-8 items on desktop */}
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className="max-h-[180px] sm:max-h-[280px] overflow-y-auto"
|
|
onScroll={handleScroll}
|
|
>
|
|
{showSearchHint ? (
|
|
<div className="flex flex-col items-center justify-center py-4 text-center px-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Type {MIN_SEARCH_LENGTH - throttledSearch.trim().length} more character
|
|
{MIN_SEARCH_LENGTH - throttledSearch.trim().length > 1 ? "s" : ""} to search
|
|
</p>
|
|
</div>
|
|
) : actualLoading ? (
|
|
<div className="flex items-center justify-center py-4">
|
|
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
|
|
</div>
|
|
) : actualDocuments.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-4 text-center px-4">
|
|
<FileText className="h-5 w-5 text-muted-foreground/55 mb-1" />
|
|
<p className="text-sm text-muted-foreground">No documents found</p>
|
|
</div>
|
|
) : (
|
|
<div className="py-1">
|
|
{/* SurfSense Documentation Section */}
|
|
{surfsenseDocsList.length > 0 && (
|
|
<>
|
|
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
|
|
SurfSense Docs
|
|
</div>
|
|
{surfsenseDocsList.map((doc) => {
|
|
const docKey = `${doc.document_type}:${doc.id}`;
|
|
const isAlreadySelected = selectedKeys.has(docKey);
|
|
const selectableIndex = selectableDocuments.findIndex(
|
|
(d) => d.document_type === doc.document_type && d.id === doc.id
|
|
);
|
|
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
|
|
|
return (
|
|
<button
|
|
key={docKey}
|
|
ref={(el) => {
|
|
if (el && selectableIndex >= 0) {
|
|
itemRefs.current.set(selectableIndex, el);
|
|
}
|
|
}}
|
|
type="button"
|
|
onClick={() => !isAlreadySelected && handleSelectDocument(doc)}
|
|
onMouseEnter={() => {
|
|
if (!isAlreadySelected && selectableIndex >= 0) {
|
|
setHighlightedIndex(selectableIndex);
|
|
}
|
|
}}
|
|
disabled={isAlreadySelected}
|
|
className={cn(
|
|
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
|
|
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
|
isHighlighted && "bg-accent"
|
|
)}
|
|
>
|
|
<span className="shrink-0 text-muted-foreground text-sm">
|
|
{getConnectorIcon(doc.document_type)}
|
|
</span>
|
|
<span className="flex-1 text-sm truncate" title={doc.title}>
|
|
{doc.title}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{/* User Documents Section */}
|
|
{userDocsList.length > 0 && (
|
|
<>
|
|
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
|
|
Your Documents
|
|
</div>
|
|
{userDocsList.map((doc) => {
|
|
const docKey = `${doc.document_type}:${doc.id}`;
|
|
const isAlreadySelected = selectedKeys.has(docKey);
|
|
const selectableIndex = selectableDocuments.findIndex(
|
|
(d) => d.document_type === doc.document_type && d.id === doc.id
|
|
);
|
|
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
|
|
|
|
return (
|
|
<button
|
|
key={docKey}
|
|
ref={(el) => {
|
|
if (el && selectableIndex >= 0) {
|
|
itemRefs.current.set(selectableIndex, el);
|
|
}
|
|
}}
|
|
type="button"
|
|
onClick={() => !isAlreadySelected && handleSelectDocument(doc)}
|
|
onMouseEnter={() => {
|
|
if (!isAlreadySelected && selectableIndex >= 0) {
|
|
setHighlightedIndex(selectableIndex);
|
|
}
|
|
}}
|
|
disabled={isAlreadySelected}
|
|
className={cn(
|
|
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
|
|
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
|
isHighlighted && "bg-accent"
|
|
)}
|
|
>
|
|
<span className="shrink-0 text-muted-foreground text-sm">
|
|
{getConnectorIcon(doc.document_type)}
|
|
</span>
|
|
<span className="flex-1 text-sm truncate" title={doc.title}>
|
|
{doc.title}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{/* Loading indicator for additional pages */}
|
|
{isLoadingMore && (
|
|
<div className="flex items-center justify-center py-2">
|
|
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|