feat(frontend): add RAG Database management page with document CRUD UI
Sub-phase 1.5.3: Full RAG Database page with document listing, expandable chunk viewer, delete with confirmation, and document upload. Adds TypeScript types, API functions, TanStack Query hooks (useQuery + useMutation with cache invalidation), and three new components (DocumentList, ChunkList, DocumentUpload). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
9a7329c5f8
commit
c10318b7f7
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Source**: User request (2026-04-23)
|
||||
**Scope**: Frontend navigation + RAG Database management page + page-aware chunking with chunk PDFs
|
||||
**Status**: 🔄 In Progress — Feature 1 ✅ Complete, Feature 2 backend ✅ Complete, Feature 2 frontend & Feature 3 pending
|
||||
**Status**: 🔄 In Progress — Features 1-2 ✅ Complete, Feature 3 pending
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -205,12 +205,12 @@ class DeleteResponse(BaseModel):
|
|||
- [x] `GET /api/v1/documents` returns all documents with chunk counts
|
||||
- [x] `DELETE /api/v1/documents/{document_id}` removes all chunks from ChromaDB + associated chunk PDFs
|
||||
- [x] `DELETE /api/v1/chunks/{chunk_id}` removes a single chunk
|
||||
- [ ] RAG Database page shows all documents with chunk counts
|
||||
- [ ] User can expand a document to see its chunks
|
||||
- [ ] User can delete a document (with confirmation)
|
||||
- [ ] User can delete individual chunks (with confirmation)
|
||||
- [ ] User can upload documents from this page
|
||||
- [ ] Stats displayed: total documents, total chunks
|
||||
- [x] RAG Database page shows all documents with chunk counts
|
||||
- [x] User can expand a document to see its chunks
|
||||
- [x] User can delete a document (with confirmation)
|
||||
- [x] User can delete individual chunks (with confirmation)
|
||||
- [x] User can upload documents from this page
|
||||
- [x] Stats displayed: total documents, total chunks
|
||||
- [ ] Uploading a file with existing filename triggers automatic replacement (old data deleted first)
|
||||
|
||||
---
|
||||
|
|
@ -531,7 +531,7 @@ Feature 3 (Page-Aware Chunking) ← Modifies ingestion pipeline
|
|||
|-----------|---------|-------|---------|----------|--------|
|
||||
| 1.5.1 | 1 | Nav bar + routing + page scaffold | None | NavBar, LTTPage, RAGDatabasePage, App.tsx refactor | ✅ Complete |
|
||||
| 1.5.2 | 2 | Backend CRUD for documents/chunks | documents router, RAGService methods, schemas | None | ✅ Complete |
|
||||
| 1.5.3 | 2 | Frontend RAG Database page | None | RAGDatabasePage, DocumentList, ChunkList, DocumentUpload, API hooks | 📋 Pending |
|
||||
| 1.5.3 | 2 | Frontend RAG Database page | None | RAGDatabasePage, DocumentList, ChunkList, DocumentUpload, API hooks | ✅ Complete |
|
||||
| 1.5.4 | 3 | Page-aware parsing & chunking | pdf_parser, chunking, metadata enhancements | None | 📋 Pending |
|
||||
| 1.5.5 | 3 | Chunk PDF generation & storage | pdf_extractor, config, ingest pipeline refactor | None | 📋 Pending |
|
||||
| 1.5.6 | 3 | Chunk file serving + frontend links | documents router endpoint | ResponsePanel clickable links, ChunkList updates | 📋 Pending |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import React from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import type { ChunkInfo } from '../types'
|
||||
|
||||
interface ChunkListProps {
|
||||
chunks: ChunkInfo[]
|
||||
isLoading: boolean
|
||||
onDeleteChunk: (chunkId: string) => void
|
||||
isDeleting: boolean
|
||||
}
|
||||
|
||||
export const ChunkList: React.FC<ChunkListProps> = ({
|
||||
chunks,
|
||||
isLoading,
|
||||
onDeleteChunk,
|
||||
isDeleting,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-4">
|
||||
<div
|
||||
data-testid="skeleton-line"
|
||||
className="h-4 bg-gray-200 rounded animate-pulse w-full"
|
||||
/>
|
||||
<div
|
||||
data-testid="skeleton-line"
|
||||
className="h-4 bg-gray-200 rounded animate-pulse w-11/12"
|
||||
/>
|
||||
<div
|
||||
data-testid="skeleton-line"
|
||||
className="h-4 bg-gray-200 rounded animate-pulse w-4/5"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
No chunks found
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 p-4">
|
||||
{chunks.map((chunk) => (
|
||||
<div
|
||||
key={chunk.chunk_id}
|
||||
data-testid="chunk-row"
|
||||
className="border border-gray-200 rounded p-3 bg-gray-50 flex items-start justify-between"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase">
|
||||
Chunk {chunk.chunk_index}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
Page: {chunk.page_number !== null ? chunk.page_number : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 truncate" title={chunk.content_summary}>
|
||||
{chunk.content_summary.length > 100
|
||||
? `${chunk.content_summary.slice(0, 100)}...`
|
||||
: chunk.content_summary}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
data-testid="delete-chunk-btn"
|
||||
onClick={() => onDeleteChunk(chunk.chunk_id)}
|
||||
disabled={isDeleting}
|
||||
className="flex items-center space-x-1 px-2 py-1 text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed ml-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import React from 'react'
|
||||
import { FileText, ChevronDown, ChevronRight, Trash2 } from 'lucide-react'
|
||||
import type { DocumentInfo } from '../types'
|
||||
|
||||
interface DocumentListProps {
|
||||
documents: DocumentInfo[]
|
||||
expandedId: string | null
|
||||
onToggleExpand: (documentId: string) => void
|
||||
onDelete: (documentId: string, filename: string) => void
|
||||
isDeleting: boolean
|
||||
}
|
||||
|
||||
export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
documents,
|
||||
expandedId,
|
||||
onToggleExpand,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{documents.map((doc) => (
|
||||
<div
|
||||
key={doc.document_id}
|
||||
data-testid="document-card"
|
||||
className="border border-gray-200 rounded-lg bg-white overflow-hidden"
|
||||
>
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<FileText className="w-5 h-5 text-gray-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 truncate">{doc.filename}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{doc.chunk_count} chunks • Uploaded {doc.upload_date}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
data-testid="view-chunks-btn"
|
||||
onClick={() => onToggleExpand(doc.document_id)}
|
||||
className="flex items-center space-x-1 px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors duration-200"
|
||||
>
|
||||
{expandedId === doc.document_id ? (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
<span>Hide Chunks</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span>View Chunks</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
data-testid="delete-document-btn"
|
||||
onClick={() => onDelete(doc.document_id, doc.filename)}
|
||||
disabled={isDeleting}
|
||||
className="flex items-center space-x-1 px-3 py-1.5 text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import React from 'react'
|
||||
import { Upload, Loader2, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
|
||||
interface DocumentUploadProps {
|
||||
onUpload: (file: File) => void
|
||||
isLoading: boolean
|
||||
success: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const DocumentUpload: React.FC<DocumentUploadProps> = ({
|
||||
onUpload,
|
||||
isLoading,
|
||||
success,
|
||||
error,
|
||||
}) => {
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
onUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.docx,.txt"
|
||||
onChange={handleFileChange}
|
||||
disabled={isLoading}
|
||||
className="hidden"
|
||||
id="upload-file-input"
|
||||
data-testid="upload-file-input"
|
||||
/>
|
||||
<label
|
||||
htmlFor="upload-file-input"
|
||||
className={`inline-flex items-center space-x-2 px-4 py-2 rounded cursor-pointer transition-colors duration-200 ${
|
||||
isLoading
|
||||
? 'bg-gray-300 cursor-not-allowed'
|
||||
: 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Uploading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>Upload</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center space-x-2 text-green-700">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-sm">{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center space-x-2 text-red-700">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import axios from 'axios'
|
||||
import type { QueryRequest, QueryResponse, IngestResponse } from '../types'
|
||||
import type { QueryRequest, QueryResponse, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse } from '../types'
|
||||
|
||||
const BASE_URL: string = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/api/v1'
|
||||
|
||||
|
|
@ -18,3 +18,23 @@ export const ingestDocument = async (file: File): Promise<IngestResponse> => {
|
|||
})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export const listDocuments = async (): Promise<DocumentListResponse> => {
|
||||
const resp = await apiClient.get<DocumentListResponse>('/documents')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export const listChunks = async (documentId: string): Promise<ChunkInfo[]> => {
|
||||
const resp = await apiClient.get<ChunkInfo[]>(`/documents/${documentId}/chunks`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export const deleteDocument = async (documentId: string): Promise<DeleteResponse> => {
|
||||
const resp = await apiClient.delete<DeleteResponse>(`/documents/${documentId}`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export const deleteChunk = async (chunkId: string): Promise<DeleteResponse> => {
|
||||
const resp = await apiClient.delete<DeleteResponse>(`/chunks/${chunkId}`)
|
||||
return resp.data
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import { QueryClient, QueryClientProvider, useMutation } from '@tanstack/react-query'
|
||||
import { queryDocument, ingestDocument } from './api'
|
||||
import type { QueryRequest, QueryResponse, IngestResponse } from '../types'
|
||||
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { queryDocument, ingestDocument, listDocuments, listChunks, deleteDocument, deleteChunk } from './api'
|
||||
import type { QueryRequest, QueryResponse, IngestResponse, DocumentListResponse, ChunkInfo, DeleteResponse } from '../types'
|
||||
|
||||
export const queryClient = new QueryClient()
|
||||
|
||||
|
|
@ -17,6 +17,41 @@ export const useIngestDocument = () => {
|
|||
})
|
||||
}
|
||||
|
||||
export const useDocuments = () => {
|
||||
return useQuery<DocumentListResponse, Error>({
|
||||
queryKey: ['documents'],
|
||||
queryFn: listDocuments,
|
||||
})
|
||||
}
|
||||
|
||||
export const useDocumentChunks = (documentId: string | null) => {
|
||||
return useQuery<ChunkInfo[], Error>({
|
||||
queryKey: ['documents', documentId, 'chunks'],
|
||||
queryFn: () => listChunks(documentId!),
|
||||
enabled: documentId !== null,
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteDocument = () => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<DeleteResponse, Error, string>({
|
||||
mutationFn: deleteDocument,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documents'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteChunk = () => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<DeleteResponse, Error, string>({
|
||||
mutationFn: deleteChunk,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documents'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const AppQueryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,144 @@
|
|||
import React from 'react'
|
||||
import { Database } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import { Database, AlertCircle } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useDocuments, useDocumentChunks, useDeleteDocument, useDeleteChunk, useIngestDocument } from '../lib/queries'
|
||||
import { DocumentList } from '../components/DocumentList'
|
||||
import { ChunkList } from '../components/ChunkList'
|
||||
import { DocumentUpload } from '../components/DocumentUpload'
|
||||
|
||||
export const RAGDatabasePage: React.FC = () => {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
|
||||
const { data: documentsData, isLoading: isLoadingDocuments, error: documentsError } = useDocuments()
|
||||
const { data: chunks, isLoading: isLoadingChunks } = useDocumentChunks(expandedId)
|
||||
const deleteDocumentMutation = useDeleteDocument()
|
||||
const deleteChunkMutation = useDeleteChunk()
|
||||
const ingestDocumentMutation = useIngestDocument()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handleToggleExpand = (documentId: string) => {
|
||||
setExpandedId(expandedId === documentId ? null : documentId)
|
||||
}
|
||||
|
||||
const handleDeleteDocument = (documentId: string, filename: string) => {
|
||||
const doc = documentsData?.documents.find((d) => d.document_id === documentId)
|
||||
const chunkCount = doc?.chunk_count ?? 0
|
||||
if (window.confirm(`Delete "${filename}" and all its ${chunkCount} chunks?`)) {
|
||||
deleteDocumentMutation.mutate(documentId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteChunk = (chunkId: string) => {
|
||||
const chunk = chunks?.find((c) => c.chunk_id === chunkId)
|
||||
const chunkIndex = chunk?.chunk_index ?? 0
|
||||
if (window.confirm(`Delete chunk ${chunkIndex}?`)) {
|
||||
deleteChunkMutation.mutate(chunkId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = (file: File) => {
|
||||
setUploadSuccess(null)
|
||||
setUploadError(null)
|
||||
ingestDocumentMutation.mutate(file, {
|
||||
onSuccess: (data) => {
|
||||
setUploadSuccess(`Uploaded ${data.filename}`)
|
||||
queryClient.invalidateQueries({ queryKey: ['documents'] })
|
||||
setTimeout(() => setUploadSuccess(null), 3000)
|
||||
},
|
||||
onError: (error) => {
|
||||
setUploadError(error.message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoadingDocuments) {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse w-1/3" />
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-1/4" />
|
||||
<div className="space-y-3">
|
||||
<div className="h-20 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-20 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (documentsError) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>Failed to load documents: {documentsError.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const documents = documentsData?.documents ?? []
|
||||
const hasDocuments = documents.length > 0
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-white/50">
|
||||
<div className="text-center space-y-2">
|
||||
<Database className="mx-auto w-12 h-12 text-gray-600" />
|
||||
<div className="text-gray-700 font-semibold">RAG Database Management — Coming Soon</div>
|
||||
<div className="h-full flex flex-col bg-gray-50">
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">RAG Database</h1>
|
||||
<DocumentUpload
|
||||
onUpload={handleUpload}
|
||||
isLoading={ingestDocumentMutation.isPending}
|
||||
success={uploadSuccess}
|
||||
error={uploadError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasDocuments && (
|
||||
<div className="px-6 py-3 bg-white border-b border-gray-200">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total: {documentsData?.total_documents ?? 0} documents,{' '}
|
||||
{documentsData?.total_chunks ?? 0} chunks
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{!hasDocuments ? (
|
||||
<div className="h-full flex items-center justify-center bg-white/50 rounded-lg border border-gray-200">
|
||||
<div className="text-center space-y-2">
|
||||
<Database className="mx-auto w-12 h-12 text-gray-600" />
|
||||
<div className="text-gray-700 font-semibold">No documents uploaded yet</div>
|
||||
<div className="text-sm text-gray-500">Upload a document to get started</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{documents.map((doc) => (
|
||||
<div key={doc.document_id} className="space-y-0">
|
||||
<DocumentList
|
||||
documents={[doc]}
|
||||
expandedId={expandedId}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onDelete={handleDeleteDocument}
|
||||
isDeleting={deleteDocumentMutation.isPending}
|
||||
/>
|
||||
{expandedId === doc.document_id && (
|
||||
<div className="ml-6 border-l-2 border-gray-200 pl-4">
|
||||
<ChunkList
|
||||
chunks={chunks ?? []}
|
||||
isLoading={isLoadingChunks}
|
||||
onDeleteChunk={handleDeleteChunk}
|
||||
isDeleting={deleteChunkMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,3 +20,29 @@ export interface IngestResponse {
|
|||
chunk_count: number
|
||||
filename: string
|
||||
}
|
||||
|
||||
export interface DocumentInfo {
|
||||
document_id: string
|
||||
filename: string
|
||||
chunk_count: number
|
||||
upload_date: string
|
||||
}
|
||||
|
||||
export interface ChunkInfo {
|
||||
chunk_id: string
|
||||
chunk_index: number
|
||||
content_summary: string
|
||||
page_number: number | null
|
||||
chunk_file_path: string | null
|
||||
}
|
||||
|
||||
export interface DocumentListResponse {
|
||||
documents: DocumentInfo[]
|
||||
total_documents: number
|
||||
total_chunks: number
|
||||
}
|
||||
|
||||
export interface DeleteResponse {
|
||||
deleted: boolean
|
||||
message: string
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue