diff --git a/app/api/configs/route.ts b/app/api/configs/route.ts index 36b2436..ea286a9 100644 --- a/app/api/configs/route.ts +++ b/app/api/configs/route.ts @@ -5,7 +5,7 @@ export async function GET(request: Request) { const apiKey = request.headers.get('X-API-KEY'); try { - const response = await fetch(`${backendUrl}/api/v1/configs`, { + const response = await fetch(`${backendUrl}/api/v1/configs/`, { headers: { 'X-API-KEY': apiKey || '', }, @@ -38,7 +38,7 @@ export async function POST(request: NextRequest) { const body=await request.json(); const backendUrl=process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'; - const response=await fetch(`${backendUrl}/api/v1/configs`, { + const response=await fetch(`${backendUrl}/api/v1/configs/`, { method:'POST', body:JSON.stringify(body), headers:{ diff --git a/app/api/document/[document_id]/route.ts b/app/api/document/[document_id]/route.ts new file mode 100644 index 0000000..125812b --- /dev/null +++ b/app/api/document/[document_id]/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server'; + + +export async function GET(request: Request, + { params }: { params: Promise<{ document_id: string }> } +) { + + const { document_id } = await params; + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'; + const apiKey = request.headers.get('X-API-KEY'); + + if (!apiKey) { + return NextResponse.json( + { error: 'Missing X-API-KEY header' }, + { status: 401 } + ); + } + + try { + const response = await fetch(`${backendUrl}/api/v1/documents/${document_id}?include_url=true`, { + headers: { + 'X-API-KEY': apiKey, + }, + }); + + // Handle empty responses (204 No Content, etc.) + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + return NextResponse.json(data, { status: 200 }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message, data: null }, + { status: 500 } + ); + } +} + +export async function DELETE(request: Request, + { params }: { params: Promise<{ document_id: string }> } +) { + const { document_id } = await params; + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'; + const apiKey = request.headers.get('X-API-KEY'); + + if (!apiKey) { + return NextResponse.json( + { error: 'Missing X-API-KEY header' }, + { status: 401 } + ); + } + + try { + const response = await fetch(`${backendUrl}/api/v1/documents/${document_id}`, { + method: 'DELETE', + headers: { + 'X-API-KEY': apiKey, + }, + }); + + // Handle empty responses (204 No Content, etc.) + const text = await response.text(); + const data = text ? JSON.parse(text) : { success: true }; + + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + return NextResponse.json(data, { status: 200 }); + } catch (error: any) { + console.error('Delete error:', error); + return NextResponse.json( + { error: 'Failed to delete document', details: error.message }, + { status: 500 } + ); + } +} diff --git a/app/api/document/route.ts b/app/api/document/route.ts new file mode 100644 index 0000000..89aa062 --- /dev/null +++ b/app/api/document/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server'; + + +export async function GET(request: Request) { + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'; + const apiKey = request.headers.get('X-API-KEY'); + + if (!apiKey) { + return NextResponse.json( + { error: 'Missing X-API-KEY header' }, + { status: 401 } + ); + } + + try { + const response = await fetch(`${backendUrl}/api/v1/documents/`, { + headers: { + 'X-API-KEY': apiKey, + }, + }); + + // Handle empty responses (204 No Content, etc.) + const text = await response.text(); + const data = text ? JSON.parse(text) : []; + + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + return NextResponse.json(data, { status: response.status }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message, data: null }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + // Get the API key from request headers + const apiKey = request.headers.get('X-API-KEY'); + + if (!apiKey) { + return NextResponse.json( + { error: 'Missing X-API-KEY header' }, + { status: 401 } + ); + } + + // Get the form data from the request + const formData = await request.formData(); + + // Get backend URL from environment variable + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'; + + // Forward the request to the actual backend + const response = await fetch(`${backendUrl}/api/v1/documents/`, { + method: 'POST', + body: formData, + headers: { + 'X-API-KEY': apiKey, + + }, + }); + + // Handle empty responses (204 No Content, etc.) + const text = await response.text(); + const data = text ? JSON.parse(text) : { success: true }; + + // Return the response with the same status code + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + return NextResponse.json(data, { status: response.status }); + } catch (error: any) { + console.error('Proxy error:', error); + return NextResponse.json( + { error: 'Failed to forward request to backend', details: error.message }, + { status: 500 } + ); + } +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 4cadccf..22f1d5d 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -30,8 +30,9 @@ interface SidebarProps { export default function Sidebar({ collapsed, activeRoute = '/evaluations' }: SidebarProps) { const router = useRouter(); const [expandedMenus, setExpandedMenus] = useState>({ - 'Tasks': true, + 'Capabilities': true, 'Evaluations': true, + 'Documents': true, 'Configurations': false, }); @@ -56,7 +57,7 @@ export default function Sidebar({ collapsed, activeRoute = '/evaluations' }: Sid const navItems: MenuItem[] = [ { - name: 'Tasks', + name: 'Capabilities', icon: ( @@ -71,6 +72,7 @@ export default function Sidebar({ collapsed, activeRoute = '/evaluations' }: Sid { name: 'Text-to-Speech', route: '/text-to-speech', comingSoon: true }, ] }, + { name: 'Documents', route: '/document' }, // { name: 'Model Testing', route: '/model-testing', comingSoon: true }, // { name: 'Guardrails', route: '/guardrails', comingSoon: true }, // { name: 'Redteaming', route: '/redteaming', comingSoon: true }, diff --git a/app/datasets/page.tsx b/app/datasets/page.tsx index a1358f3..cfdcf73 100644 --- a/app/datasets/page.tsx +++ b/app/datasets/page.tsx @@ -794,4 +794,4 @@ export function UploadDatasetModal({ ); -} +} \ No newline at end of file diff --git a/app/document/page.tsx b/app/document/page.tsx new file mode 100644 index 0000000..89c051e --- /dev/null +++ b/app/document/page.tsx @@ -0,0 +1,1013 @@ +"use client" +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation' +import { APIKey, STORAGE_KEY } from '../keystore/page'; +import Sidebar from '../components/Sidebar'; +import { useToast } from '../components/Toast'; + +// Backend response interface +export interface Document { + id: string; // UUID from backend + fname: string; // Filename from backend + object_store_url: string; + signed_url?: string; // Signed URL for accessing the document + file_size?: number; // File size in bytes (stored from client upload) + inserted_at?: string; + updated_at?: string; +} + +export default function DocumentPage() { + const router = useRouter(); + const toast = useToast(); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [documents, setDocuments] = useState([]); + const [selectedDocument, setSelectedDocument] = useState(null); + const [isLoadingDocument, setIsLoadingDocument] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [apiKey, setApiKey] = useState(null); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage] = useState(10); + + // Load API key from localStorage + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const keys = JSON.parse(stored); + if (keys.length > 0) { + setApiKey(keys[0]); + } + } catch (e) { + console.error('Failed to load API key:', e); + } + } + }, []); + + // Fetch documents from backend when API key is available + useEffect(() => { + if (apiKey) { + fetchDocuments(); + } + }, [apiKey]); + + const fetchDocuments = async () => { + if (!apiKey) { + setError('No API key found. Please add an API key in the Keystore.'); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/document', { + method: 'GET', + headers: { + 'X-API-KEY': apiKey.key, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || errorData.message || `Failed to fetch documents: ${response.status}`); + } + + const data = await response.json(); + console.log('Fetched documents:', data); // Debug log + const documentList = Array.isArray(data) ? data : (data.data || []); + + // Load file sizes from localStorage + const fileSizeMap = JSON.parse(localStorage.getItem('document_file_sizes') || '{}'); + const documentsWithSize = documentList.map((doc: Document) => ({ + ...doc, + file_size: fileSizeMap[doc.id] || doc.file_size + })); + + console.log('Document list:', documentsWithSize); // Debug log + setDocuments(documentsWithSize); + } catch (err: any) { + console.error('Failed to fetch documents:', err); + setError(err.message || 'Failed to fetch documents'); + } finally { + setIsLoading(false); + } + }; + + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setSelectedFile(file); + }; + + const handleUpload = async () => { + if (!apiKey || !selectedFile) return; + + setIsUploading(true); + + try { + // Prepare FormData for upload + const formData = new FormData(); + formData.append('src', selectedFile); + + // Upload to backend + const response = await fetch('/api/document', { + method: 'POST', + body: formData, + headers: { + 'X-API-KEY': apiKey.key, + } + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || errorData.message || `Upload failed with status ${response.status}`); + } + + const data = await response.json(); + console.log('Document uploaded successfully:', data); + + // Store file size in localStorage for the uploaded document + if (selectedFile && data.data?.id) { + const fileSizeMap = JSON.parse(localStorage.getItem('document_file_sizes') || '{}'); + fileSizeMap[data.data.id] = selectedFile.size; + localStorage.setItem('document_file_sizes', JSON.stringify(fileSizeMap)); + } + + // Refresh documents list + await fetchDocuments(); + + // Reset form + setSelectedFile(null); + + // Close modal + setIsModalOpen(false); + + toast.success('Document uploaded successfully!'); + } catch (error) { + console.error('Upload error:', error); + toast.error(`Failed to upload document: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setIsUploading(false); + } + }; + + const handleDeleteDocument = async (documentId: string) => { + if (!apiKey) { + toast.error('No API key found'); + return; + } + + // Using browser confirm for now - could be replaced with a custom modal later + if (!confirm('Are you sure you want to delete this document?')) { + return; + } + + try { + const response = await fetch(`/api/document/${documentId}`, { + method: 'DELETE', + headers: { + 'X-API-KEY': apiKey.key, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || errorData.message || `Delete failed with status ${response.status}`); + } + + // Clear selected document if it was deleted + if (selectedDocument?.id === documentId) { + setSelectedDocument(null); + } + + // Refresh documents list + await fetchDocuments(); + toast.success('Document deleted successfully'); + } catch (error) { + console.error('Delete error:', error); + toast.error(`Failed to delete document: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + + // Pagination calculations + const indexOfLastItem = currentPage * itemsPerPage; + const indexOfFirstItem = indexOfLastItem - itemsPerPage; + const currentDocuments = documents.slice(indexOfFirstItem, indexOfLastItem); + const totalPages = Math.ceil(documents.length / itemsPerPage); + + const paginate = (pageNumber: number) => setCurrentPage(pageNumber); + + // Fetch full document details including signed_url + const handleSelectDocument = async (doc: Document) => { + if (!apiKey) return; + + setIsLoadingDocument(true); + try { + const response = await fetch(`/api/document/${doc.id}`, { + method: 'GET', + headers: { + 'X-API-KEY': apiKey.key, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch document details'); + } + + const data = await response.json(); + const documentDetails = data.data || data; + + // Load file size from localStorage + const fileSizeMap = JSON.parse(localStorage.getItem('document_file_sizes') || '{}'); + const docWithSize = { + ...documentDetails, + file_size: fileSizeMap[documentDetails.id] || documentDetails.file_size + }; + + setSelectedDocument(docWithSize); + } catch (err) { + console.error('Failed to fetch document details:', err); + toast.error('Failed to load document preview'); + // Fallback to the basic document info + setSelectedDocument(doc); + } finally { + setIsLoadingDocument(false); + } + }; + + return ( +
+
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {/* Title Section with Collapse Button */} +
+
+ +
+

Documents

+

Manage your uploaded documents

+
+
+
+ + {/* Content Area - Split View */} +
+ {/* Left Side: Document List */} +
+ setIsModalOpen(true)} + isLoading={isLoading} + error={error} + apiKey={apiKey} + totalPages={totalPages} + currentPage={currentPage} + onPageChange={paginate} + /> +
+ + {/* Right Side: Document Preview */} +
+ +
+
+
+
+ + {/* Upload Document Modal */} + {isModalOpen && ( + { + setIsModalOpen(false); + setSelectedFile(null); + }} + /> + )} +
+ ); +} + +// ============ DOCUMENT LISTING COMPONENT ============ +interface DocumentListingProps { + documents: Document[]; + selectedDocument: Document | null; + onSelect: (document: Document) => void; + onDelete: (documentId: string) => void; + onUploadNew: () => void; + isLoading: boolean; + error: string | null; + apiKey: APIKey | null; + totalPages: number; + currentPage: number; + onPageChange: (page: number) => void; +} + +function DocumentListing({ + documents, + selectedDocument, + onSelect, + onDelete, + onUploadNew, + isLoading, + error, + apiKey, + totalPages, + currentPage, + onPageChange, +}: DocumentListingProps) { + const formatDate = (dateString?: string) => { + if (!dateString) return 'N/A'; + try { + const date = new Date(dateString); + const istDate = new Date(date.getTime() + (5.5 * 60 * 60 * 1000)); + const day = istDate.getDate(); + const month = istDate.toLocaleDateString('en-US', { month: 'short' }); + const year = istDate.getFullYear(); + const hours = String(istDate.getHours()).padStart(2, '0'); + const minutes = String(istDate.getMinutes()).padStart(2, '0'); + return `${day} ${month} ${year}, ${hours}:${minutes}`; + } catch { + return dateString; + } + }; + + return ( +
+
+
+

+ Your Documents +

+ +
+
+ +
+ {/* Loading State */} + {isLoading && documents.length === 0 ? ( +
+ + + +

Loading documents...

+
+ ) : !apiKey ? ( +
+ + + +

No API key found

+

Please add an API key in the Keystore

+ + Go to Keystore + +
+ ) : error ? ( +
+

+ Error: {error} +

+
+ ) : documents.length === 0 ? ( +
+ + + +

No documents found

+

Upload your first document to get started

+ +
+ ) : ( +
+ {documents.map((doc) => ( +
onSelect(doc)} + className={`border rounded-lg p-3 cursor-pointer transition-colors ${ + selectedDocument?.id === doc.id + ? 'ring-2 ring-offset-1' + : '' + }`} + style={{ + backgroundColor: selectedDocument?.id === doc.id + ? 'hsl(202, 100%, 95%)' + : 'hsl(0, 0%, 100%)', + borderColor: selectedDocument?.id === doc.id + ? 'hsl(202, 100%, 50%)' + : 'hsl(0, 0%, 85%)' + }} + > +
+
+
+ + + +

+ {doc.fname} +

+
+
+
{formatDate(doc.inserted_at)}
+
+
+ +
+
+ ))} +
+ )} + + {/* Pagination */} + {!isLoading && !error && apiKey && documents.length > 0 && totalPages > 1 && ( +
+

+ Page {currentPage} of {totalPages} +

+
+ + +
+
+ )} +
+
+ ); +} + +// ============ DOCUMENT PREVIEW COMPONENT ============ +interface DocumentPreviewProps { + document: Document | null; + isLoading: boolean; +} + +function DocumentPreview({ document, isLoading }: DocumentPreviewProps) { + const [imageLoadError, setImageLoadError] = useState(false); + + // Reset error state when document changes + useEffect(() => { + setImageLoadError(false); + }, [document?.id]); + + const formatDate = (dateString?: string) => { + if (!dateString) return 'N/A'; + try { + const date = new Date(dateString); + // Add 5.5 hours (IST offset) since the input is already in IST but parsed as UTC + const istDate = new Date(date.getTime() + (5.5 * 60 * 60 * 1000)); + const day = istDate.getDate(); + const month = istDate.toLocaleDateString('en-US', { month: 'short' }); + const year = istDate.getFullYear(); + const hours = String(istDate.getHours()).padStart(2, '0'); + const minutes = String(istDate.getMinutes()).padStart(2, '0'); + return `${day} ${month} ${year}, ${hours}:${minutes}`; + } catch { + return dateString; + } + }; + + const getFileExtension = (filename: string) => { + const parts = filename.split('.'); + return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''; + }; + + // const getFileSize = + + const getMimeType = (filename: string) => { + const ext = getFileExtension(filename); + const mimeTypes: { [key: string]: string } = { + 'pdf': 'application/pdf', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'txt': 'text/plain', + 'doc': 'application/msword', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }; + return mimeTypes[ext] || 'application/octet-stream'; + }; + + if (isLoading) { + return ( +
+
+ + + +

Loading document...

+
+
+ ); + } + + if (!document) { + return ( +
+
+ + + +

No document selected

+

Select a document from the list to preview

+
+
+ ); + } + + console.log('Document preview:', document); // Debug log + + return ( +
+
+
+

+ {document.fname} +

+
+
+
File Type
+
{getFileExtension(document.fname).toUpperCase() || 'Unknown'}
+
+
+
File Size
+
+ {document.file_size + ? document.file_size < 1024 * 1024 + ? `${Math.round(document.file_size / 1024)} KB` + : `${(document.file_size / (1024 * 1024)).toFixed(2)} MB` + : 'N/A'} +
+
+
+
Uploaded at
+
{formatDate(document.inserted_at)}
+
+
+
+ + {/* Preview Area */} +
+

Preview

+ + {/* Info message if signed_url is not available */} + {!document.signed_url && document.object_store_url && ( +
+

+ ⚠️ Direct preview unavailable. The backend is not generating signed URLs. Please download the file to view it. +

+
+ )} + + {document.signed_url ? ( + <> + {getMimeType(document.fname).startsWith('image/') ? ( + imageLoadError ? ( +
+

+ Failed to load image preview. Check console for details. +

+
+ ) : ( + {document.fname} { + console.error('Failed to load image:', document.signed_url); + setImageLoadError(true); + }} + onLoad={() => console.log('Image loaded successfully')} + /> + ) + ) : getMimeType(document.fname) === 'application/pdf' ? ( +