| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import React, { useState, useEffect, useCallback, useRef } from 'react'; |
| import { Upload, FileText, Loader2, BookOpen, Pin, EyeOff, Trash2, CheckCircle2, AlertCircle, Clock, RefreshCw, } from 'lucide-react'; |
| import { listPersonaDocuments, uploadProjectItem, attachPersonaDocument, setPersonaDocumentMode, deletePersonaDocumentPermanently, } from '../inventoryApi'; |
| |
| |
| |
| const MODE_CONFIG = { |
| indexed: { label: 'Indexed', icon: BookOpen, color: 'text-green-400', desc: 'Included in RAG retrieval' }, |
| pinned: { label: 'Pinned', icon: Pin, color: 'text-amber-400', desc: 'Always included (boosted)' }, |
| excluded: { label: 'Excluded', icon: EyeOff, color: 'text-red-400', desc: 'Excluded from retrieval' }, |
| }; |
| const STATUS_CONFIG = { |
| ready: { label: 'Ready', icon: CheckCircle2, color: 'text-green-400' }, |
| indexing: { label: 'Indexing...', icon: Loader2, color: 'text-amber-400' }, |
| error: { label: 'Error', icon: AlertCircle, color: 'text-red-400' }, |
| not_indexed: { label: 'Not indexed', icon: Clock, color: 'text-white/30' }, |
| }; |
| |
| |
| |
| export function PersonaDocumentsPanel({ projectId, backendUrl, apiKey, onChanged }) { |
| const [docs, setDocs] = useState([]); |
| const [loading, setLoading] = useState(true); |
| const [error, setError] = useState(null); |
| const [uploading, setUploading] = useState(false); |
| const [actionInProgress, setActionInProgress] = useState(null); |
| const fileInputRef = useRef(null); |
| |
| |
| |
| const loadDocs = useCallback(async () => { |
| try { |
| const data = await listPersonaDocuments(backendUrl, projectId, { apiKey }); |
| setDocs(data.documents || []); |
| setError(null); |
| } |
| catch (e) { |
| setError(e.message); |
| } |
| finally { |
| setLoading(false); |
| } |
| }, [backendUrl, projectId, apiKey]); |
| useEffect(() => { loadDocs(); }, [loadDocs]); |
| |
| |
| |
| const handleUpload = useCallback(async (files) => { |
| if (!files || files.length === 0) |
| return; |
| setUploading(true); |
| setError(null); |
| for (const file of Array.from(files)) { |
| try { |
| |
| const result = await uploadProjectItem(backendUrl, projectId, file, { |
| apiKey, |
| category: 'file', |
| }); |
| if (result.ok && result.item?.id) { |
| |
| await attachPersonaDocument(backendUrl, projectId, result.item.id, 'indexed', { apiKey }); |
| } |
| } |
| catch (e) { |
| setError(`Upload failed for ${file.name}: ${e.message}`); |
| } |
| } |
| setUploading(false); |
| loadDocs(); |
| onChanged?.(); |
| }, [backendUrl, projectId, apiKey, loadDocs, onChanged]); |
| |
| |
| |
| const handleModeChange = useCallback(async (itemId, newMode) => { |
| setActionInProgress(itemId); |
| try { |
| await setPersonaDocumentMode(backendUrl, projectId, itemId, newMode, { apiKey }); |
| loadDocs(); |
| } |
| catch (e) { |
| setError(e.message); |
| } |
| finally { |
| setActionInProgress(null); |
| } |
| }, [backendUrl, projectId, apiKey, loadDocs]); |
| |
| |
| |
| const handleDelete = useCallback(async (doc) => { |
| if (!confirm(`Delete "${doc.original_name || doc.name}"? This will remove the file from storage.`)) |
| return; |
| setActionInProgress(doc.id); |
| try { |
| await deletePersonaDocumentPermanently(backendUrl, projectId, doc.id, { apiKey }); |
| loadDocs(); |
| onChanged?.(); |
| } |
| catch (e) { |
| setError(e.message); |
| } |
| finally { |
| setActionInProgress(null); |
| } |
| }, [backendUrl, projectId, apiKey, loadDocs, onChanged]); |
| |
| |
| |
| const renderDocRow = (doc) => { |
| const props = doc.properties || {}; |
| const status = props.index_status || 'not_indexed'; |
| const statusCfg = STATUS_CONFIG[status] || STATUS_CONFIG.not_indexed; |
| const modeCfg = MODE_CONFIG[doc.mode] || MODE_CONFIG.indexed; |
| const StatusIcon = statusCfg.icon; |
| const isActive = actionInProgress === doc.id; |
| const ext = (doc.original_name || doc.name || '').split('.').pop()?.toUpperCase() || 'FILE'; |
| const sizeStr = doc.size_bytes > 1048576 |
| ? `${(doc.size_bytes / 1048576).toFixed(1)} MB` |
| : doc.size_bytes > 0 |
| ? `${Math.round(doc.size_bytes / 1024)} KB` |
| : ''; |
| return (<div key={doc.id} className={[ |
| 'flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all', |
| isActive ? 'opacity-40 pointer-events-none' : '', |
| 'bg-white/[0.03] border-white/10 hover:bg-white/[0.06] hover:border-white/15', |
| ].join(' ')}> |
| {/* File icon */} |
| <div className="w-9 h-9 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0"> |
| <FileText size={16} className="text-blue-400/70"/> |
| </div> |
| |
| {/* Name + meta */} |
| <div className="flex-1 min-w-0"> |
| <div className="text-xs text-white font-medium truncate"> |
| {doc.original_name || doc.name} |
| </div> |
| <div className="flex items-center gap-2 mt-0.5"> |
| <span className="text-[9px] text-white/25 uppercase">{ext}</span> |
| {sizeStr && <span className="text-[9px] text-white/25">{sizeStr}</span>} |
| {/* Index status */} |
| <span className={`text-[9px] flex items-center gap-0.5 ${statusCfg.color}`}> |
| <StatusIcon size={9} className={status === 'indexing' ? 'animate-spin' : ''}/> |
| {statusCfg.label} |
| </span> |
| {props.chunk_count != null && props.chunk_count > 0 && (<span className="text-[9px] text-white/20">{props.chunk_count} chunks</span>)} |
| </div> |
| </div> |
| |
| {/* Mode selector */} |
| <div className="flex items-center gap-1 shrink-0"> |
| {['indexed', 'pinned', 'excluded'].map((m) => { |
| const cfg = MODE_CONFIG[m]; |
| const MIcon = cfg.icon; |
| const active = doc.mode === m; |
| return (<button key={m} title={`${cfg.label}: ${cfg.desc}`} onClick={() => { if (!active) |
| handleModeChange(doc.id, m); }} className={[ |
| 'p-1 rounded transition-all', |
| active |
| ? `${cfg.color} bg-white/10` |
| : 'text-white/20 hover:text-white/40 hover:bg-white/5', |
| ].join(' ')}> |
| <MIcon size={12}/> |
| </button>); |
| })} |
| </div> |
| |
| {/* Delete button */} |
| <button title="Remove document" onClick={() => handleDelete(doc)} className="p-1 rounded text-white/15 hover:text-red-400/70 hover:bg-red-500/10 transition-all shrink-0"> |
| <Trash2 size={12}/> |
| </button> |
| </div>); |
| }; |
| |
| |
| |
| return (<div className="space-y-3"> |
| {/* Header with upload button */} |
| <div className="flex items-center justify-between"> |
| <div className="flex items-center gap-2"> |
| <BookOpen size={14} className="text-blue-400/60"/> |
| <span className="text-xs text-white/60 font-medium">Knowledge Base</span> |
| <span className="text-[10px] text-white/25">({docs.length} docs)</span> |
| </div> |
| <button onClick={() => fileInputRef.current?.click()} disabled={uploading} className="flex items-center gap-1.5 px-2.5 py-1.5 text-[10px] font-medium rounded-lg bg-blue-500/15 text-blue-400 hover:bg-blue-500/25 border border-blue-500/20 transition-all disabled:opacity-40"> |
| {uploading ? (<Loader2 size={11} className="animate-spin"/>) : (<Upload size={11}/>)} |
| Add Document |
| </button> |
| <input ref={fileInputRef} type="file" multiple accept=".pdf,.txt,.md,.doc,.docx,.csv,.json,.yaml,.yml" className="hidden" onChange={(e) => handleUpload(e.target.files)}/> |
| </div> |
| |
| {/* Error */} |
| {error && (<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20 text-[10px] text-red-400"> |
| <AlertCircle size={12}/> |
| {error} |
| <button onClick={() => setError(null)} className="ml-auto text-red-400/50 hover:text-red-400">dismiss</button> |
| </div>)} |
| |
| {/* Loading */} |
| {loading && (<div className="flex items-center justify-center py-8"> |
| <Loader2 size={18} className="animate-spin text-white/20"/> |
| </div>)} |
| |
| {/* Document list */} |
| {!loading && docs.length === 0 && (<div className="flex flex-col items-center justify-center py-8 gap-2 text-white/20"> |
| <FileText size={24} className="text-white/10"/> |
| <p className="text-[10px]">No documents attached to this persona.</p> |
| <p className="text-[9px] text-white/15">Add PDFs, text, or markdown files to enable Chat-with-Docs.</p> |
| </div>)} |
| |
| {!loading && docs.length > 0 && (<div className="space-y-1.5"> |
| {docs.map(renderDocRow)} |
| </div>)} |
| |
| {/* Refresh */} |
| {!loading && docs.length > 0 && (<div className="flex justify-center pt-1"> |
| <button onClick={loadDocs} className="flex items-center gap-1 text-[9px] text-white/20 hover:text-white/40 transition-colors"> |
| <RefreshCw size={9}/> |
| Refresh |
| </button> |
| </div>)} |
| </div>); |
| } |
|
|