Spaces:
Running
Running
| import { useState, useEffect } from "react"; | |
| import { | |
| Upload, | |
| Trash2, | |
| FileText, | |
| Check, | |
| Loader2, | |
| Database, | |
| X, | |
| } from "lucide-react"; | |
| import { | |
| getDocuments, | |
| uploadDocument, | |
| processDocument, | |
| deleteDocument, | |
| type ApiDocument, | |
| type DocumentStatus, | |
| } from "../../services/api"; | |
| interface KnowledgeManagementProps { | |
| open: boolean; | |
| onClose: () => void; | |
| } | |
| const getUserId = (): string | null => { | |
| const stored = localStorage.getItem("chatbot_user"); | |
| if (!stored) return null; | |
| return (JSON.parse(stored).user_id as string) ?? null; | |
| }; | |
| export default function KnowledgeManagement({ | |
| open, | |
| onClose, | |
| }: KnowledgeManagementProps) { | |
| const [documents, setDocuments] = useState<ApiDocument[]>([]); | |
| const [loadingDocs, setLoadingDocs] = useState(false); | |
| const [docsError, setDocsError] = useState<string | null>(null); | |
| const [uploading, setUploading] = useState(false); | |
| const [uploadError, setUploadError] = useState<string | null>(null); | |
| const [processing, setProcessing] = useState<string | null>(null); | |
| const [deleting, setDeleting] = useState<string | null>(null); | |
| useEffect(() => { | |
| if (!open) return; | |
| const userId = getUserId(); | |
| if (!userId) return; | |
| loadDocuments(userId); | |
| }, [open]); | |
| const loadDocuments = async (userId: string) => { | |
| setLoadingDocs(true); | |
| setDocsError(null); | |
| try { | |
| setDocuments(await getDocuments(userId)); | |
| } catch (err) { | |
| setDocsError( | |
| err instanceof Error ? err.message : "Failed to load documents" | |
| ); | |
| } finally { | |
| setLoadingDocs(false); | |
| } | |
| }; | |
| const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const files = e.target.files; | |
| if (!files || files.length === 0) return; | |
| const userId = getUserId(); | |
| if (!userId) return; | |
| setUploading(true); | |
| setUploadError(null); | |
| for (let i = 0; i < files.length; i++) { | |
| const file = files[i]; | |
| try { | |
| const uploadRes = await uploadDocument(userId, file); | |
| const newDoc: ApiDocument = { | |
| id: uploadRes.data.id, | |
| filename: uploadRes.data.filename, | |
| status: "pending", | |
| file_size: file.size, | |
| file_type: file.name.split(".").pop() ?? "", | |
| created_at: new Date().toISOString(), | |
| }; | |
| setDocuments((prev) => [newDoc, ...prev]); | |
| await processDocumentById(userId, uploadRes.data.id); | |
| } catch (err) { | |
| setUploadError(err instanceof Error ? err.message : "Upload failed"); | |
| } | |
| } | |
| setUploading(false); | |
| e.target.value = ""; | |
| }; | |
| const processDocumentById = async (userId: string, docId: string) => { | |
| setProcessing(docId); | |
| setDocuments((prev) => | |
| prev.map((d) => | |
| d.id === docId ? { ...d, status: "processing" as DocumentStatus } : d | |
| ) | |
| ); | |
| try { | |
| await processDocument(userId, docId); | |
| setDocuments((prev) => | |
| prev.map((d) => | |
| d.id === docId ? { ...d, status: "completed" as DocumentStatus } : d | |
| ) | |
| ); | |
| } catch { | |
| setDocuments((prev) => | |
| prev.map((d) => | |
| d.id === docId ? { ...d, status: "failed" as DocumentStatus } : d | |
| ) | |
| ); | |
| } finally { | |
| setProcessing(null); | |
| } | |
| }; | |
| const handleDeleteDocument = async (docId: string) => { | |
| const userId = getUserId(); | |
| if (!userId) return; | |
| setDeleting(docId); | |
| try { | |
| await deleteDocument(userId, docId); | |
| setDocuments((prev) => prev.filter((d) => d.id !== docId)); | |
| } catch (err) { | |
| console.error("Delete failed:", err); | |
| } finally { | |
| setDeleting(null); | |
| } | |
| }; | |
| const deleteAllDocuments = async () => { | |
| if (!window.confirm("Are you sure you want to delete all documents?")) | |
| return; | |
| const userId = getUserId(); | |
| if (!userId) return; | |
| for (const doc of documents) { | |
| try { | |
| await deleteDocument(userId, doc.id); | |
| } catch { | |
| // continue deleting others | |
| } | |
| } | |
| setDocuments([]); | |
| }; | |
| const formatFileSize = (bytes: number) => { | |
| if (bytes < 1024) return bytes + " B"; | |
| if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB"; | |
| return (bytes / (1024 * 1024)).toFixed(2) + " MB"; | |
| }; | |
| const formatDate = (isoString: string) => { | |
| return new Date(isoString).toLocaleString(); | |
| }; | |
| const renderStatus = (doc: ApiDocument) => { | |
| if (doc.status === "completed") { | |
| return ( | |
| <div className="flex items-center gap-1.5 text-green-600"> | |
| <Check className="w-3.5 h-3.5" /> | |
| <span className="text-xs font-medium">Processed</span> | |
| </div> | |
| ); | |
| } | |
| if (doc.status === "processing" || processing === doc.id) { | |
| return ( | |
| <div className="flex items-center gap-1.5 text-blue-600"> | |
| <Loader2 className="w-3.5 h-3.5 animate-spin" /> | |
| <span className="text-xs">Processing...</span> | |
| </div> | |
| ); | |
| } | |
| // pending or failed | |
| return ( | |
| <button | |
| onClick={() => { | |
| const userId = getUserId(); | |
| if (userId) processDocumentById(userId, doc.id); | |
| }} | |
| disabled={processing === doc.id} | |
| className="flex items-center gap-1.5 bg-gradient-to-r from-[#00C853] to-[#00A843] text-white px-3 py-1.5 rounded-lg hover:from-[#00A843] hover:to-[#00962B] transition disabled:opacity-50 disabled:cursor-not-allowed text-xs" | |
| > | |
| <Database className="w-3.5 h-3.5" /> | |
| {doc.status === "failed" ? "Retry Process" : "Process to Knowledge"} | |
| </button> | |
| ); | |
| }; | |
| if (!open) return null; | |
| return ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> | |
| <div className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col m-4"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between p-4 border-b border-slate-200 bg-gradient-to-r from-[#FF8F00] to-[#FF6F00]"> | |
| <div className="flex items-center gap-2"> | |
| <Database className="w-5 h-5 text-white" /> | |
| <h2 className="text-lg text-white">Knowledge Management</h2> | |
| </div> | |
| <button | |
| onClick={onClose} | |
| className="text-white/80 hover:text-white transition" | |
| > | |
| <X className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| {/* Content */} | |
| <div className="flex-1 overflow-y-auto p-4"> | |
| {/* Upload Section */} | |
| <div className="mb-4"> | |
| <label | |
| htmlFor="file-upload" | |
| className="flex items-center justify-center gap-2 border-2 border-dashed border-slate-300 rounded-lg p-6 cursor-pointer hover:border-[#4FC3F7] hover:bg-slate-50 transition" | |
| > | |
| <Upload className="w-5 h-5 text-slate-600" /> | |
| <div className="text-center"> | |
| <p className="text-slate-900 font-medium text-sm"> | |
| Upload Documents (PDF, DOCX, TXT) | |
| </p> | |
| <p className="text-xs text-slate-500 mt-0.5"> | |
| Click to browse or drag and drop | |
| </p> | |
| </div> | |
| <input | |
| id="file-upload" | |
| type="file" | |
| accept=".pdf,.docx,.txt" | |
| multiple | |
| onChange={handleFileUpload} | |
| className="hidden" | |
| disabled={uploading} | |
| /> | |
| </label> | |
| {uploadError && ( | |
| <p className="mt-2 text-xs text-red-600 bg-red-50 border border-red-200 px-3 py-2 rounded-lg"> | |
| {uploadError} | |
| </p> | |
| )} | |
| </div> | |
| {/* Documents List */} | |
| <div className="space-y-2"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <h3 className="text-sm text-slate-900 font-medium"> | |
| Documents ({documents.length}) | |
| </h3> | |
| {documents.length > 0 && ( | |
| <button | |
| onClick={deleteAllDocuments} | |
| className="text-xs text-red-600 hover:text-red-700 flex items-center gap-1" | |
| > | |
| <Trash2 className="w-3.5 h-3.5" /> | |
| Delete All | |
| </button> | |
| )} | |
| </div> | |
| {loadingDocs ? ( | |
| <div className="flex justify-center py-8"> | |
| <Loader2 className="w-6 h-6 animate-spin text-slate-400" /> | |
| </div> | |
| ) : docsError ? ( | |
| <p className="text-center text-sm text-red-600 py-4"> | |
| {docsError} | |
| </p> | |
| ) : documents.length === 0 ? ( | |
| <div className="text-center py-8"> | |
| <FileText className="w-12 h-12 text-slate-300 mx-auto mb-3" /> | |
| <p className="text-slate-500 text-sm"> | |
| No documents uploaded yet | |
| </p> | |
| <p className="text-xs text-slate-400 mt-1"> | |
| Upload files to build your knowledge base | |
| </p> | |
| </div> | |
| ) : ( | |
| documents.map((doc) => ( | |
| <div | |
| key={doc.id} | |
| className="bg-slate-50 rounded-lg p-3 border border-slate-200" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <FileText className="w-8 h-8 text-red-500 flex-shrink-0 mt-0.5" /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-start justify-between gap-2"> | |
| <div className="flex-1 min-w-0"> | |
| <h4 className="text-slate-900 font-medium truncate text-sm"> | |
| {doc.filename} | |
| </h4> | |
| <p className="text-xs text-slate-500 mt-0.5"> | |
| {formatFileSize(doc.file_size)} •{" "} | |
| {formatDate(doc.created_at)} | |
| </p> | |
| </div> | |
| <button | |
| onClick={() => handleDeleteDocument(doc.id)} | |
| disabled={deleting === doc.id} | |
| className="text-slate-400 hover:text-red-600 transition flex-shrink-0 disabled:opacity-50" | |
| title="Delete document" | |
| > | |
| {deleting === doc.id ? ( | |
| <Loader2 className="w-4 h-4 animate-spin" /> | |
| ) : ( | |
| <Trash2 className="w-4 h-4" /> | |
| )} | |
| </button> | |
| </div> | |
| <div className="mt-2 flex items-center gap-2"> | |
| {renderStatus(doc)} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| {/* Footer */} | |
| <div className="border-t border-slate-200 p-3 bg-slate-50 rounded-b-xl"> | |
| <p className="text-[10px] text-slate-500 text-center"> | |
| Supported formats: PDF, DOCX, TXT | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |