diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..c9c0c1e8 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,7 @@ +reviews: + auto_review: + enabled: true + labels: + - "ready-for-review" + base_branches: + - ".*" # allows all branches diff --git a/CLAUDE.md b/CLAUDE.md index b8e32cfb..5990c8bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,17 @@ import { useApp } from '@/app/lib/context/AppContext'; const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar } = useApp(); ``` +## Code Quality Guidelines + +Follow these rules when writing or modifying code in this repository: + +- **File size limit**: Do not let any file exceed **500 LOC**. If a file is approaching or has crossed this limit, split it into smaller modules (extract sub-components, hooks, utilities, or types into their own files). +- **Single Responsibility Principle (SRP)**: Each component, hook, function, or module should do one thing and have one reason to change. If a component handles data fetching, business logic, and UI rendering all together, split it — extract data fetching into a hook, business logic into a utility, and keep the component focused on presentation. +- **Don't Repeat Yourself (DRY)**: Before writing new logic, search the codebase for existing implementations. Reuse and extend rather than duplicate. If you spot the same pattern emerging in 2+ places, extract it into a shared helper, hook, or component in `app/lib/` or `app/components/`. +- **Reuse existing components and icons**: Always check `app/components/` and `app/components/icons/` before creating a new component or icon. Prefer composing or extending existing primitives over authoring new ones. New icons go in `app/components/icons/` as hand-authored React components — do not inline SVGs in feature code. +- **Reuse existing utilities and hooks**: Check `app/lib/utils/`, `app/lib/utils.ts`, and `app/hooks/` before adding new helpers. Domain-specific utilities belong under `app/lib/utils//`. +- **Reuse existing types**: Shared types live in `app/lib/types/` and `app/lib/models.ts` — import from there instead of redefining shapes locally. + ## API Client & Error Handling The BFF layer uses [apiClient.ts](app/lib/apiClient.ts) which forwards requests from Next.js route handlers to the backend at `BACKEND_URL` (defaults to `http://localhost:8000`). Key patterns: diff --git a/app/(main)/configurations/prompt-editor/page.tsx b/app/(main)/configurations/prompt-editor/page.tsx index 19677150..7746bb89 100644 --- a/app/(main)/configurations/prompt-editor/page.tsx +++ b/app/(main)/configurations/prompt-editor/page.tsx @@ -405,10 +405,7 @@ function PromptEditorContent() { style={{ backgroundColor: colors.bg.secondary }} >
-
-

- Loading configuration... -

+
) : ( diff --git a/app/(main)/document/page.tsx b/app/(main)/document/page.tsx index ac1b5a6b..fa885ad0 100644 --- a/app/(main)/document/page.tsx +++ b/app/(main)/document/page.tsx @@ -15,10 +15,13 @@ import { import { DocumentListing } from "@/app/components/document/DocumentListing"; import { DocumentPreview } from "@/app/components/document/DocumentPreview"; import { UploadDocumentModal } from "@/app/components/document/UploadDocumentModal"; +import DeleteDocumentModal from "@/app/components/document/DeleteDocumentModal"; +import Modal from "@/app/components/Modal"; import { DEFAULT_PAGE_LIMIT, MAX_DOCUMENT_SIZE_BYTES, MAX_DOCUMENT_SIZE_MB, + MAX_DOCUMENT_UPLOAD_BATCH, } from "@/app/lib/constants"; import { Document } from "@/app/lib/types/document"; @@ -30,10 +33,14 @@ export default function DocumentPage() { null, ); const [isLoadingDocument, setIsLoadingDocument] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); + const [selectedFiles, setSelectedFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadPhase, setUploadPhase] = useState("uploading"); + const [currentUploadIndex, setCurrentUploadIndex] = useState(0); + const [documentToDelete, setDocumentToDelete] = useState( + null, + ); const abortUploadRef = useRef<(() => void) | null>(null); const { activeKey: apiKey, isAuthenticated } = useAuth(); @@ -57,78 +64,126 @@ export default function DocumentPage() { }); const handleFileSelect = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + if (files.length === 0) return; + + const remaining = MAX_DOCUMENT_UPLOAD_BATCH - selectedFiles.length; + if (remaining <= 0) return; + + const accepted: File[] = []; + let oversizedCount = 0; + for (const file of files.slice(0, remaining)) { + if (file.size > MAX_DOCUMENT_SIZE_BYTES) { + oversizedCount += 1; + continue; + } + accepted.push(file); + } - if (file.size > MAX_DOCUMENT_SIZE_BYTES) { + if (oversizedCount > 0) { toast.error( - `File size exceeds ${MAX_DOCUMENT_SIZE_MB} MB limit. Please select a smaller file within ${MAX_DOCUMENT_SIZE_MB} MB.`, + `${oversizedCount} file${oversizedCount > 1 ? "s" : ""} exceed the ${MAX_DOCUMENT_SIZE_MB} MB limit and were skipped.`, + ); + } + if (files.length > remaining) { + toast.warning( + `You can upload up to ${MAX_DOCUMENT_UPLOAD_BATCH} documents at a time.`, ); - event.target.value = ""; - return; } - setSelectedFile(file); + if (accepted.length > 0) { + setSelectedFiles((prev) => [...prev, ...accepted]); + } }; - const handleUpload = async () => { - if (!isAuthenticated || !selectedFile) return; + const handleRemoveSelectedFile = (index: number) => { + setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); + }; - setIsUploading(true); + const uploadOneFile = async (file: File): Promise => { setUploadProgress(0); setUploadPhase("uploading"); - try { - const formData = new FormData(); - formData.append("src", selectedFile); + const formData = new FormData(); + formData.append("src", file); - const { promise, abort } = uploadWithProgress<{ data?: { id: string } }>( - "/api/document", - apiKey?.key ?? "", - formData, - (percent, phase) => { - setUploadProgress(percent); - setUploadPhase(phase); - }, - ); - abortUploadRef.current = abort; + const { promise, abort } = uploadWithProgress<{ data?: { id: string } }>( + "/api/document", + apiKey?.key ?? "", + formData, + (percent, phase) => { + setUploadProgress(percent); + setUploadPhase(phase); + }, + ); + abortUploadRef.current = abort; + + try { const data = await promise; - if (selectedFile && data.data?.id) { + if (data.data?.id) { const fileSizeMap = JSON.parse( localStorage.getItem("document_file_sizes") || "{}", ); - fileSizeMap[data.data.id] = selectedFile.size; + fileSizeMap[data.data.id] = file.size; localStorage.setItem( "document_file_sizes", JSON.stringify(fileSizeMap), ); } - - refetch(); - setSelectedFile(null); - setIsModalOpen(false); - - toast.success("Document uploaded successfully!"); + return true; } catch (error) { console.error("Upload error:", error); toast.error( - `Failed to upload document: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to upload "${file.name}": ${error instanceof Error ? error.message : "Unknown error"}`, ); + return false; } finally { - setIsUploading(false); abortUploadRef.current = null; } }; - const handleDeleteDocument = async (documentId: string) => { + const handleUpload = async () => { + if (!isAuthenticated || selectedFiles.length === 0) return; + + setIsUploading(true); + setCurrentUploadIndex(0); + + let successCount = 0; + for (let i = 0; i < selectedFiles.length; i += 1) { + setCurrentUploadIndex(i); + const ok = await uploadOneFile(selectedFiles[i]); + if (ok) successCount += 1; + } + + refetch(); + setSelectedFiles([]); + setIsModalOpen(false); + setIsUploading(false); + setCurrentUploadIndex(0); + + if (successCount > 0) { + toast.success( + successCount === 1 + ? "Document uploaded successfully!" + : `${successCount} documents uploaded successfully!`, + ); + } + }; + + const handleRequestDelete = (documentId: string) => { if (!isAuthenticated) { toast.error("Please log in to continue"); return; } + const doc = documents.find((d) => d.id === documentId); + if (doc) setDocumentToDelete(doc); + }; - if (!confirm("Are you sure you want to delete this document?")) { - return; - } + const handleConfirmDelete = async () => { + if (!documentToDelete) return; + const documentId = documentToDelete.id; + setDocumentToDelete(null); try { await apiFetch(`/api/document/${documentId}`, apiKey?.key ?? "", { @@ -180,7 +235,7 @@ export default function DocumentPage() { }; return ( -
+
@@ -190,13 +245,13 @@ export default function DocumentPage() { subtitle="Manage your uploaded documents" /> -
-
+
+
setIsModalOpen(true)} isLoading={isLoading} isLoadingMore={isLoadingMore} @@ -205,7 +260,7 @@ export default function DocumentPage() { />
-
+
+ {/* Mobile/tablet — preview rendered in a modal */} +
+ setSelectedDocument(null)} + title={selectedDocument?.fname ?? "Document Preview"} + maxWidth="max-w-3xl" + maxHeight="max-h-[90vh]" + > +
+ +
+
+
+ setSelectedFiles([])} onUpload={handleUpload} onClose={() => { abortUploadRef.current?.(); setIsModalOpen(false); - setSelectedFile(null); + setSelectedFiles([]); setUploadProgress(0); setUploadPhase("uploading"); + setCurrentUploadIndex(0); }} /> + + setDocumentToDelete(null)} + onConfirm={handleConfirmDelete} + />
); } diff --git a/app/(main)/evaluations/[id]/page.tsx b/app/(main)/evaluations/[id]/page.tsx index 11a933fc..ce318c1c 100644 --- a/app/(main)/evaluations/[id]/page.tsx +++ b/app/(main)/evaluations/[id]/page.tsx @@ -252,7 +252,7 @@ export default function EvaluationReport() { job.status.toLowerCase() !== "failed"; const segmentedClass = - "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all cursor-pointer border border-transparent text-text-primary hover:bg-black/4 hover:shadow-[0_0_0_1px_rgba(0,0,0,0.06)] data-[selected=true]:bg-bg-primary data-[selected=true]:border-border data-[selected=true]:shadow-[0_1px_2px_rgba(0,0,0,0.08)] data-[selected=true]:hover:bg-bg-primary data-[selected=true]:hover:shadow-[0_1px_2px_rgba(0,0,0,0.08)]"; + "inline-flex items-center gap-1.5 px-4 py-1.5 text-xs font-semibold rounded-full transition-colors cursor-pointer text-accent-primary/70 hover:text-accent-primary data-[selected=true]:bg-accent-primary data-[selected=true]:text-white data-[selected=true]:shadow-[0_1px_2px_rgba(0,0,0,0.12)] data-[selected=true]:hover:bg-accent-hover"; return (
@@ -292,7 +292,7 @@ export default function EvaluationReport() {
-
+
-
- -
- {isLoading && collections.length === 0 ? ( -
- Loading knowledge bases... -
- ) : collections.length === 0 ? ( -
- No knowledge bases yet. Create your first one! -
- ) : ( -
- {collections.map((collection) => { - const isSelected = selectedCollection?.id === collection.id; - return ( - - ); - })} -
- )} -
-
- -
+ + +
{showCreateForm ? ( -
-
-

- Create Knowledge Base -

- -
- - {/* Name Input */} -
- -
- - {/* Description Input */} -
- -