"use client"; import { useAtom } from "jotai"; import { ChevronDown, Dot, File as FileIcon, FolderOpen, Upload, X } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useMemo, useRef, useState } from "react"; import { useDropzone } from "react-dropzone"; import { toast } from "sonner"; import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Progress } from "@/components/ui/progress"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { trackDocumentUploadFailure, trackDocumentUploadStarted, trackDocumentUploadSuccess, } from "@/lib/posthog/events"; interface SelectedFolder { path: string; name: string; } interface DocumentUploadTabProps { searchSpaceId: string; onSuccess?: () => void; onAccordionStateChange?: (isExpanded: boolean) => void; } const audioFileTypes = { "audio/mpeg": [".mp3", ".mpeg", ".mpga"], "audio/mp4": [".mp4", ".m4a"], "audio/wav": [".wav"], "audio/webm": [".webm"], "text/markdown": [".md", ".markdown"], "text/plain": [".txt"], }; const commonTypes = { "application/pdf": [".pdf"], "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"], "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"], "application/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"], "text/html": [".html", ".htm"], "text/csv": [".csv"], "image/jpeg": [".jpg", ".jpeg"], "image/png": [".png"], "image/bmp": [".bmp"], "image/webp": [".webp"], "image/tiff": [".tiff"], }; const FILE_TYPE_CONFIG: Record> = { LLAMACLOUD: { ...commonTypes, "application/msword": [".doc"], "application/vnd.ms-word.document.macroEnabled.12": [".docm"], "application/msword-template": [".dot"], "application/vnd.ms-word.template.macroEnabled.12": [".dotm"], "application/vnd.ms-powerpoint": [".ppt"], "application/vnd.ms-powerpoint.template.macroEnabled.12": [".pptm"], "application/vnd.ms-powerpoint.template": [".pot"], "application/vnd.openxmlformats-officedocument.presentationml.template": [".potx"], "application/vnd.ms-excel": [".xls"], "application/vnd.ms-excel.sheet.macroEnabled.12": [".xlsm"], "application/vnd.ms-excel.sheet.binary.macroEnabled.12": [".xlsb"], "application/vnd.ms-excel.workspace": [".xlw"], "application/rtf": [".rtf"], "application/xml": [".xml"], "application/epub+zip": [".epub"], "text/tab-separated-values": [".tsv"], "text/html": [".html", ".htm", ".web"], "image/gif": [".gif"], "image/svg+xml": [".svg"], ...audioFileTypes, }, DOCLING: { ...commonTypes, "text/asciidoc": [".adoc", ".asciidoc"], "text/html": [".html", ".htm", ".xhtml"], "image/tiff": [".tiff", ".tif"], ...audioFileTypes, }, default: { ...commonTypes, "application/msword": [".doc"], "message/rfc822": [".eml"], "application/epub+zip": [".epub"], "image/heic": [".heic"], "application/vnd.ms-outlook": [".msg"], "application/vnd.oasis.opendocument.text": [".odt"], "text/x-org": [".org"], "application/pkcs7-signature": [".p7s"], "application/vnd.ms-powerpoint": [".ppt"], "text/x-rst": [".rst"], "application/rtf": [".rtf"], "text/tab-separated-values": [".tsv"], "application/vnd.ms-excel": [".xls"], "application/xml": [".xml"], ...audioFileTypes, }, }; interface FileWithId { id: string; file: File; } const MAX_FILES = 50; const MAX_TOTAL_SIZE_MB = 200; const MAX_TOTAL_SIZE_BYTES = MAX_TOTAL_SIZE_MB * 1024 * 1024; const toggleRowClass = "flex items-center justify-between rounded-lg bg-slate-400/5 dark:bg-white/5 p-3"; export function DocumentUploadTab({ searchSpaceId, onSuccess, onAccordionStateChange, }: DocumentUploadTabProps) { const t = useTranslations("upload_documents"); const [files, setFiles] = useState([]); const [uploadProgress, setUploadProgress] = useState(0); const [accordionValue, setAccordionValue] = useState(""); const [shouldSummarize, setShouldSummarize] = useState(false); const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom); const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation; const fileInputRef = useRef(null); const [selectedFolder, setSelectedFolder] = useState(null); const [watchFolder, setWatchFolder] = useState(true); const [folderSubmitting, setFolderSubmitting] = useState(false); const isElectron = typeof window !== "undefined" && !!window.electronAPI?.browseFiles; const acceptedFileTypes = useMemo(() => { const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE; return FILE_TYPE_CONFIG[etlService || "default"] || FILE_TYPE_CONFIG.default; }, []); const supportedExtensions = useMemo( () => Array.from(new Set(Object.values(acceptedFileTypes).flat())).sort(), [acceptedFileTypes] ); const onDrop = useCallback( (acceptedFiles: File[]) => { setSelectedFolder(null); setFiles((prev) => { const newEntries = acceptedFiles.map((f) => ({ id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`, file: f, })); const newFiles = [...prev, ...newEntries]; if (newFiles.length > MAX_FILES) { toast.error(t("max_files_exceeded"), { description: t("max_files_exceeded_desc", { max: MAX_FILES }), }); return prev; } const newTotalSize = newFiles.reduce((sum, entry) => sum + entry.file.size, 0); if (newTotalSize > MAX_TOTAL_SIZE_BYTES) { toast.error(t("max_size_exceeded"), { description: t("max_size_exceeded_desc", { max: MAX_TOTAL_SIZE_MB }), }); return prev; } return newFiles; }); }, [t] ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept: acceptedFileTypes, maxSize: 50 * 1024 * 1024, noClick: isElectron, disabled: files.length >= MAX_FILES, }); const handleFileInputClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); }, []); const handleBrowseFiles = useCallback(async () => { const api = window.electronAPI; if (!api?.browseFiles) return; const paths = await api.browseFiles(); if (!paths || paths.length === 0) return; setSelectedFolder(null); const fileDataList = await api.readLocalFiles(paths); const newFiles: FileWithId[] = fileDataList.map((fd) => ({ id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`, file: new File([fd.data], fd.name, { type: fd.mimeType }), })); setFiles((prev) => { const merged = [...prev, ...newFiles]; if (merged.length > MAX_FILES) { toast.error(t("max_files_exceeded"), { description: t("max_files_exceeded_desc", { max: MAX_FILES }), }); return prev; } const totalSize = merged.reduce((sum, e) => sum + e.file.size, 0); if (totalSize > MAX_TOTAL_SIZE_BYTES) { toast.error(t("max_size_exceeded"), { description: t("max_size_exceeded_desc", { max: MAX_TOTAL_SIZE_MB }), }); return prev; } return merged; }); }, [t]); const handleBrowseFolder = useCallback(async () => { const api = window.electronAPI; if (!api?.selectFolder) return; const folderPath = await api.selectFolder(); if (!folderPath) return; const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath; setFiles([]); setSelectedFolder({ path: folderPath, name: folderName }); setWatchFolder(true); }, []); const formatFileSize = (bytes: number) => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; }; const totalFileSize = files.reduce((total, entry) => total + entry.file.size, 0); const isFileCountLimitReached = files.length >= MAX_FILES; const isSizeLimitReached = totalFileSize >= MAX_TOTAL_SIZE_BYTES; const remainingFiles = MAX_FILES - files.length; const remainingSizeMB = Math.max( 0, (MAX_TOTAL_SIZE_BYTES - totalFileSize) / (1024 * 1024) ).toFixed(1); const hasContent = files.length > 0 || selectedFolder !== null; const handleAccordionChange = useCallback( (value: string) => { setAccordionValue(value); onAccordionStateChange?.(value === "supported-file-types"); }, [onAccordionStateChange] ); const handleFolderSubmit = useCallback(async () => { if (!selectedFolder) return; const api = window.electronAPI; if (!api) return; setFolderSubmitting(true); try { const numericSpaceId = Number(searchSpaceId); const result = await documentsApiService.folderIndex(numericSpaceId, { folder_path: selectedFolder.path, folder_name: selectedFolder.name, search_space_id: numericSpaceId, enable_summary: shouldSummarize, }); const rootFolderId = (result as { root_folder_id?: number })?.root_folder_id ?? null; if (watchFolder) { await api.addWatchedFolder({ path: selectedFolder.path, name: selectedFolder.name, excludePatterns: [".git", "node_modules", "__pycache__", ".DS_Store", ".obsidian", ".trash"], fileExtensions: null, rootFolderId, searchSpaceId: Number(searchSpaceId), active: true, }); toast.success(`Watching folder: ${selectedFolder.name}`); } else { toast.success(`Syncing folder: ${selectedFolder.name}`); } setSelectedFolder(null); onSuccess?.(); } catch (err) { toast.error((err as Error)?.message || "Failed to process folder"); } finally { setFolderSubmitting(false); } }, [selectedFolder, watchFolder, searchSpaceId, shouldSummarize, onSuccess]); const handleUpload = async () => { setUploadProgress(0); trackDocumentUploadStarted(Number(searchSpaceId), files.length, totalFileSize); const progressInterval = setInterval(() => { setUploadProgress((prev) => (prev >= 90 ? prev : prev + Math.random() * 10)); }, 200); const rawFiles = files.map((entry) => entry.file); uploadDocuments( { files: rawFiles, search_space_id: Number(searchSpaceId), should_summarize: shouldSummarize, }, { onSuccess: () => { clearInterval(progressInterval); setUploadProgress(100); trackDocumentUploadSuccess(Number(searchSpaceId), files.length); toast(t("upload_initiated"), { description: t("upload_initiated_desc") }); onSuccess?.(); }, onError: (error: unknown) => { clearInterval(progressInterval); setUploadProgress(0); const message = error instanceof Error ? error.message : "Upload failed"; trackDocumentUploadFailure(Number(searchSpaceId), message); toast(t("upload_error"), { description: `${t("upload_error_desc")}: ${message}`, }); }, } ); }; const renderBrowseButton = (options?: { compact?: boolean; fullWidth?: boolean }) => { const { compact, fullWidth } = options ?? {}; if (isFileCountLimitReached) return null; const sizeClass = compact ? "h-7" : "h-8"; const widthClass = fullWidth ? "w-full" : ""; if (isElectron) { return ( e.stopPropagation()}> e.stopPropagation()}> Files Folder ); } return ( ); }; return (
{/* Hidden file input for mobile browse */} {/* MOBILE DROP ZONE */}
{hasContent ? ( !selectedFolder && !isFileCountLimitReached && ( isElectron ? (
{renderBrowseButton({ compact: true, fullWidth: true })}
) : ( ) ) ) : (
{ if (!isElectron) fileInputRef.current?.click(); }} >

{isElectron ? "Select files or folder" : "Tap to select files"}

{t("file_size_limit")} {t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}

{isElectron && (
e.stopPropagation()}> {renderBrowseButton({ fullWidth: true })}
)}
)}
{/* DESKTOP DROP ZONE */}
{hasContent ? (
{isDragActive ? t("drop_files") : isFileCountLimitReached ? t("file_limit_reached") : t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })} {renderBrowseButton({ compact: true })}
) : isFileCountLimitReached ? (

{t("file_limit_reached")}

{t("file_limit_reached_desc", { max: MAX_FILES })}

) : isDragActive ? (

{t("drop_files")}

) : (

{t("drag_drop")}

{t("file_size_limit")} {t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}

{renderBrowseButton()}
)}
{/* FOLDER SELECTED */} {selectedFolder && (

{selectedFolder.name}

{selectedFolder.path}

Watch folder

Auto-sync when files change

Enable AI Summary

Improves search quality but adds latency

)} {/* FILES SELECTED */} {files.length > 0 && (

{t("selected_files", { count: files.length })} · {formatFileSize(totalFileSize)}

{files.map((entry) => (
{entry.file.name.split(".").pop() || "?"} {entry.file.name} {formatFileSize(entry.file.size)}
))}
{isUploading && (
{t("uploading_files")} {Math.round(uploadProgress)}%
)}

Enable AI Summary

Improves search quality but adds latency

)} {/* SUPPORTED FORMATS */} {t("supported_file_types")}
{supportedExtensions.map((ext) => ( {ext} ))}
); }