Merge pull request #529 from CREDO23/feat/add-jotai-tanstack

[Feat] Documents | Add Jotai & Tanstack
This commit is contained in:
Rohan Verma 2025-12-10 14:38:00 -08:00 committed by GitHub
commit 194292a641
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 948 additions and 688 deletions

View file

@ -3,7 +3,9 @@
import { ChatInput } from "@llamaindex/chat-ui";
import { Brain, Check, FolderOpen, Minus, Plus, PlusCircle, Zap } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import React, { Suspense, useCallback, useState } from "react";
import React, { Suspense, useCallback, useState, useMemo } from "react";
import { useAtom } from "jotai";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -25,7 +27,6 @@ import {
} from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useDocumentTypes } from "@/hooks/use-document-types";
import type { Document } from "@/hooks/use-documents";
import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
@ -118,11 +119,20 @@ const ConnectorSelector = React.memo(
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
// Fetch immediately (not lazy) so the button can show the correct count
const { documentTypes, isLoading, isLoaded, fetchDocumentTypes } = useDocumentTypes(
Number(search_space_id),
false
);
// Use the documentTypeCountsAtom for fetching document types
const [documentTypeCountsQuery] = useAtom(documentTypeCountsAtom);
const { data: documentTypeCountsData, isLoading, refetch: fetchDocumentTypes } = documentTypeCountsQuery;
// Transform the response into the expected format
const documentTypes = useMemo(() => {
if (!documentTypeCountsData) return [];
return Object.entries(documentTypeCountsData).map(([type, count]) => ({
type,
count,
}));
}, [documentTypeCountsData]);
const isLoaded = !!documentTypeCountsData;
// Fetch live search connectors immediately (non-indexable)
const {

View file

@ -10,6 +10,7 @@ import {
import { ArrowUpDown, Calendar, FileText, Filter, Plus, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@ -31,7 +32,11 @@ import {
TableRow,
} from "@/components/ui/table";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { type Document, type DocumentType, useDocuments } from "@/hooks/use-documents";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Document, DocumentTypeEnum } from "@/contracts/types/document.types";
import { useAtomValue } from "jotai";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
interface DocumentsDataTableProps {
searchSpaceId: number;
@ -104,8 +109,8 @@ const columns: ColumnDef<Document>[] = [
cell: ({ row }) => {
const type = row.getValue("document_type") as DocumentType;
return (
<div className="flex items-center gap-2" title={type}>
<span className="text-primary">{getConnectorIcon(type)}</span>
<div className="flex items-center gap-2" title={String(type)}>
<span className="text-primary">{getConnectorIcon(String(type))}</span>
</div>
);
},
@ -182,52 +187,59 @@ export function DocumentsDataTable({
const [sorting, setSorting] = useState<SortingState>([]);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounced(search, 300);
const [documentTypeFilter, setDocumentTypeFilter] = useState<string[]>([]);
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentTypeEnum[]>([]);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
const {data : typeCounts } = useAtomValue(documentTypeCountsAtom);
// Use server-side pagination, search, and filtering
const { documents, total, loading, fetchDocuments, searchDocuments, getDocumentTypeCounts } =
useDocuments(searchSpaceId, {
page: pageIndex,
pageSize: pageSize,
});
const fetchQueryParams = useMemo(
() => ({
search_space_id: searchSpaceId,
page: pageIndex ,
page_size: pageSize,
...(documentTypeFilter.length > 0 && { document_types: documentTypeFilter }),
}),
[searchSpaceId, pageIndex, pageSize, documentTypeFilter, debouncedSearch]
);
// Fetch document type counts on mount
useEffect(() => {
if (searchSpaceId && getDocumentTypeCounts) {
getDocumentTypeCounts().then(setTypeCounts);
const searchQueryParams = useMemo(() => {
return {
search_space_id: searchSpaceId,
page: pageIndex ,
page_size: pageSize,
...(documentTypeFilter.length > 0 && { document_types: documentTypeFilter }),
title : debouncedSearch,
}
}, [searchSpaceId, getDocumentTypeCounts]);
},[debouncedSearch, searchSpaceId, pageIndex, pageSize, documentTypeFilter, debouncedSearch])
// Refetch when pagination changes or when search/filters change
useEffect(() => {
if (searchSpaceId) {
if (debouncedSearch.trim()) {
searchDocuments?.(
debouncedSearch,
pageIndex,
pageSize,
documentTypeFilter.length > 0 ? documentTypeFilter : undefined
);
} else {
fetchDocuments?.(
pageIndex,
pageSize,
documentTypeFilter.length > 0 ? documentTypeFilter : undefined
);
}
}
}, [
pageIndex,
pageSize,
debouncedSearch,
documentTypeFilter,
searchSpaceId,
fetchDocuments,
searchDocuments,
]);
// Use query for fetching documents
const {
data: documents,
isLoading: isDocumentsLoading,
} = useQuery({
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
queryFn: () => documentsApiService.getDocuments({ queryParams : fetchQueryParams }),
staleTime: 3 * 60 * 1000, // 3 minutes
enabled: !!searchSpaceId && !debouncedSearch.trim(),
});
// Seaching
const {
data: searchedDocuments,
isLoading: isSearchedDocumentsLoading,
} = useQuery({
queryKey: cacheKeys.documents.withQueryParams(searchQueryParams),
queryFn: () => documentsApiService.searchDocuments({ queryParams : searchQueryParams }),
staleTime: 3 * 60 * 1000, // 3 minutes
enabled: !!searchSpaceId && !!debouncedSearch.trim(),
});
// Use query data when not searching, otherwise use hook data
const actualDocuments = debouncedSearch.trim() ? searchedDocuments?.items|| [] : documents?.items|| [];
const actualTotal = debouncedSearch.trim() ? searchedDocuments?.total || 0 : documents?.total || 0;
const actualLoading = debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading;
// Memoize initial row selection to prevent infinite loops
const initialRowSelection = useMemo(() => {
@ -272,14 +284,14 @@ export function DocumentsDataTable({
// Update the selected documents map when row selection changes
useEffect(() => {
if (!documents || documents.length === 0) return;
if (!actualDocuments || actualDocuments.length === 0) return;
setSelectedDocumentsMap((prev) => {
const newMap = new Map(prev);
let hasChanges = false;
// Process only current page documents
for (const doc of documents) {
for (const doc of actualDocuments) {
const docId = doc.id;
const isSelected = rowSelection[docId.toString()];
const wasInMap = newMap.has(docId);
@ -319,14 +331,14 @@ export function DocumentsDataTable({
}, [selectedDocumentsArray, onSelectionChange]);
const table = useReactTable({
data: documents || [],
data: actualDocuments || [],
columns,
getRowId: (row) => row.id.toString(),
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
manualPagination: true,
pageCount: Math.ceil(total / pageSize),
pageCount: Math.ceil(actualTotal / pageSize),
state: { sorting, rowSelection, pagination: { pageIndex, pageSize } },
});
@ -344,7 +356,7 @@ export function DocumentsDataTable({
setRowSelection(newSelection);
}, [table, rowSelection]);
const handleToggleType = useCallback((type: string, checked: boolean) => {
const handleToggleType = useCallback((type: DocumentTypeEnum, checked: boolean) => {
setDocumentTypeFilter((prev) => {
if (checked) {
return [...prev, type];
@ -358,7 +370,7 @@ export function DocumentsDataTable({
// Get available document types from type counts (memoized)
const availableTypes = useMemo(() => {
const types = Object.keys(typeCounts);
const types = typeCounts ? Object.keys(typeCounts) as DocumentTypeEnum[] : [];
return types.length > 0 ? types.sort() : [];
}, [typeCounts]);
@ -408,7 +420,7 @@ export function DocumentsDataTable({
className="flex grow justify-between gap-2 font-normal text-sm cursor-pointer"
>
<span>{type.replace(/_/g, " ")}</span>
<span className="text-xs text-muted-foreground">{typeCounts[type]}</span>
<span className="text-xs text-muted-foreground">{typeCounts?.[type]}</span>
</Label>
</div>
))}
@ -435,7 +447,7 @@ export function DocumentsDataTable({
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{selectedCount} selected {loading && "· Loading..."}
{selectedCount} selected {actualLoading && "· Loading..."}
</span>
<div className="hidden sm:block h-4 w-px bg-border mx-2" />
<div className="flex items-center gap-2 flex-wrap">
@ -453,7 +465,7 @@ export function DocumentsDataTable({
size="sm"
onClick={handleSelectPage}
className="text-xs sm:text-sm"
disabled={loading}
disabled={actualLoading}
>
Select Page
</Button>
@ -490,7 +502,7 @@ export function DocumentsDataTable({
{/* Table Container */}
<div className="border rounded-lg flex-1 min-h-0 overflow-hidden bg-background">
<div className="overflow-auto h-full">
{loading ? (
{actualLoading ? (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
@ -561,31 +573,31 @@ export function DocumentsDataTable({
{/* Footer Pagination */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs sm:text-sm text-muted-foreground border-t pt-3 md:pt-4 flex-shrink-0">
<div className="text-center sm:text-left">
Showing {pageIndex * pageSize + 1} to {Math.min((pageIndex + 1) * pageSize, total)} of{" "}
{total} documents
Showing {pageIndex * pageSize + 1} to {Math.min((pageIndex + 1) * pageSize, actualTotal)} of{" "}
{actualTotal} documents
</div>
<div className="flex items-center justify-center sm:justify-end space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
disabled={pageIndex === 0 || loading}
className="text-xs sm:text-sm"
size="sm"
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
disabled={pageIndex === 0 || actualLoading}
className="text-xs sm:text-sm"
>
Previous
</Button>
<div className="flex items-center space-x-1 text-xs sm:text-sm">
<span>Page</span>
<strong>{pageIndex + 1}</strong>
<span>of</span>
<strong>{Math.ceil(total / pageSize)}</strong>
</div>
<strong>{pageIndex + 1}</strong>
<span>of</span>
<strong>{Math.ceil(actualTotal / pageSize)}</strong>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex((p) => p + 1)}
disabled={pageIndex >= Math.ceil(total / pageSize) - 1 || loading}
className="text-xs sm:text-sm"
size="sm"
onClick={() => setPageIndex((p) => p + 1)}
disabled={pageIndex >= Math.ceil(actualTotal / pageSize) - 1 || actualLoading}
className="text-xs sm:text-sm"
>
Next
</Button>

View file

@ -1,8 +1,9 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, ExternalLink, Loader2 } from "lucide-react";
import type React from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
@ -15,7 +16,8 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useDocumentByChunk } from "@/hooks/use-document-by-chunk";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
interface SourceDetailSheetProps {
@ -46,11 +48,22 @@ export function SourceDetailSheet({
url,
children,
}: SourceDetailSheetProps) {
const { document, loading, error, fetchDocumentByChunk, clearDocument } = useDocumentByChunk();
const chunksContainerRef = useRef<HTMLDivElement>(null);
const highlightedChunkRef = useRef<HTMLDivElement>(null);
const [summaryOpen, setSummaryOpen] = useState(false);
// Add useQuery to fetch document by chunk
const {
data: document,
isLoading: isDocumentByChunkFetching,
error: documentByChunkFetchingError,
} = useQuery({
queryKey: cacheKeys.documents.byChunk(chunkId.toString()),
queryFn: () => documentsApiService.getDocumentByChunk({ chunk_id: chunkId }),
enabled: !!chunkId && open,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Check if this is a source type that should render directly from node
const isDirectRenderSource =
sourceType === "TAVILY_API" ||
@ -58,17 +71,9 @@ export function SourceDetailSheet({
sourceType === "SEARXNG_API" ||
sourceType === "BAIDU_SEARCH_API";
useEffect(() => {
if (open && chunkId && !isDirectRenderSource) {
fetchDocumentByChunk(chunkId);
} else if (!open && !isDirectRenderSource) {
clearDocument();
}
}, [open, chunkId, isDirectRenderSource, fetchDocumentByChunk, clearDocument]);
useEffect(() => {
// Scroll to highlighted chunk when document loads
if (document && highlightedChunkRef.current && chunksContainerRef.current) {
if (document) {
setTimeout(() => {
highlightedChunkRef.current?.scrollIntoView({
behavior: "smooth",
@ -76,7 +81,7 @@ export function SourceDetailSheet({
});
}, 100);
}
}, [document]);
}, [document, open]);
const handleUrlClick = (e: React.MouseEvent, clickUrl: string) => {
e.preventDefault();
@ -100,15 +105,17 @@ export function SourceDetailSheet({
</SheetDescription>
</SheetHeader>
{!isDirectRenderSource && loading && (
{!isDirectRenderSource && isDocumentByChunkFetching && (
<div className="flex items-center justify-center h-64 px-6">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{!isDirectRenderSource && error && (
{!isDirectRenderSource && documentByChunkFetchingError && (
<div className="flex items-center justify-center h-64 px-6">
<p className="text-sm text-destructive">{error}</p>
<p className="text-sm text-destructive">
{documentByChunkFetchingError.message || "Failed to load document"}
</p>
</div>
)}

View file

@ -1,5 +1,6 @@
"use client";
import { useAtom } from "jotai";
import { CheckCircle2, FileType, Info, Loader2, Tag, Upload, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
@ -7,6 +8,7 @@ import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
@ -14,7 +16,6 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { getAuthHeaders } from "@/lib/auth-utils";
import { GridPattern } from "./GridPattern";
interface DocumentUploadTabProps {
@ -25,9 +26,12 @@ export function DocumentUploadTab({ searchSpaceId }: DocumentUploadTabProps) {
const t = useTranslations("upload_documents");
const router = useRouter();
const [files, setFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
// Use the uploadDocumentMutationAtom
const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom);
const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation;
const audioFileTypes = {
"audio/mpeg": [".mp3", ".mpeg", ".mpga"],
"audio/mp4": [".mp4", ".m4a"],
@ -148,53 +152,40 @@ export function DocumentUploadTab({ searchSpaceId }: DocumentUploadTabProps) {
};
const handleUpload = async () => {
setIsUploading(true);
setUploadProgress(0);
const formData = new FormData();
files.forEach((file) => {
formData.append("files", file);
});
formData.append("search_space_id", searchSpaceId);
// Create a progress interval to simulate progress
const progressInterval = setInterval(() => {
setUploadProgress((prev) => {
if (prev >= 90) return prev;
return prev + Math.random() * 10;
});
}, 200);
try {
const progressInterval = setInterval(() => {
setUploadProgress((prev) => {
if (prev >= 90) return prev;
return prev + Math.random() * 10;
});
}, 200);
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/fileupload`,
{
method: "POST",
headers: getAuthHeaders(),
body: formData,
}
);
clearInterval(progressInterval);
setUploadProgress(100);
if (!response.ok) {
throw new Error("Upload failed");
// Use the mutation to upload documents
uploadDocuments(
{
files,
search_space_id: Number(searchSpaceId),
},
{
onSuccess: () => {
clearInterval(progressInterval);
setUploadProgress(100);
toast(t("upload_initiated"), {
description: t("upload_initiated_desc"),
});
router.push(`/dashboard/${searchSpaceId}/documents`);
},
onError: (error: any) => {
clearInterval(progressInterval);
setUploadProgress(0);
toast(t("upload_error"), {
description: `${t("upload_error_desc")}: ${error.message || "Upload failed"}`,
});
},
}
await response.json();
toast(t("upload_initiated"), {
description: t("upload_initiated_desc"),
});
router.push(`/dashboard/${searchSpaceId}/documents`);
} catch (error: any) {
setIsUploading(false);
setUploadProgress(0);
toast(t("upload_error"), {
description: `${t("upload_error_desc")}: ${error.message}`,
});
}
);
};
const getTotalFileSize = () => {

View file

@ -7,7 +7,9 @@ import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useAtom } from "jotai";
import { toast } from "sonner";
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Button } from "@/components/ui/button";
import {
@ -19,7 +21,6 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { authenticatedFetch } from "@/lib/auth-utils";
const youtubeRegex =
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
@ -33,9 +34,12 @@ export function YouTubeTab({ searchSpaceId }: YouTubeTabProps) {
const router = useRouter();
const [videoTags, setVideoTags] = useState<TagType[]>([]);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Use the createDocumentMutationAtom
const [createDocumentMutation] = useAtom(createDocumentMutationAtom);
const { mutate: createYouTubeDocument, isPending: isSubmitting } = createDocumentMutation;
const isValidYoutubeUrl = (url: string): boolean => {
return youtubeRegex.test(url);
};
@ -58,47 +62,35 @@ export function YouTubeTab({ searchSpaceId }: YouTubeTabProps) {
}
setError(null);
setIsSubmitting(true);
try {
toast(t("processing_toast"), {
description: t("processing_toast_desc"),
});
toast(t("processing_toast"), {
description: t("processing_toast_desc"),
});
const videoUrls = videoTags.map((tag) => tag.text);
const videoUrls = videoTags.map((tag) => tag.text);
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
document_type: "YOUTUBE_VIDEO",
content: videoUrls,
search_space_id: parseInt(searchSpaceId),
}),
}
);
if (!response.ok) {
throw new Error("Failed to process YouTube videos");
// Use the mutation to create YouTube documents
createYouTubeDocument(
{
document_type: "YOUTUBE_VIDEO",
content: videoUrls,
search_space_id: parseInt(searchSpaceId),
},
{
onSuccess: () => {
toast(t("success_toast"), {
description: t("success_toast_desc"),
});
router.push(`/dashboard/${searchSpaceId}/documents`);
},
onError: (error: any) => {
setError(error.message || t("error_generic"));
toast(t("error_toast"), {
description: `${t("error_toast_desc")}: ${error.message || "Failed to process YouTube videos"}`,
});
},
}
await response.json();
toast(t("success_toast"), {
description: t("success_toast_desc"),
});
router.push(`/dashboard/${searchSpaceId}/documents`);
} catch (error: any) {
setError(error.message || t("error_generic"));
toast(t("error_toast"), {
description: `${t("error_toast_desc")}: ${error.message}`,
});
} finally {
setIsSubmitting(false);
}
);
};
const handleAddTag = (text: string) => {