HomePilot / frontend /src /ui /components /PersonaDocumentsPanel.jsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
11.3 kB
/**
* PersonaDocumentsPanel — manages documents attached to a persona for
* Chat-with-Docs (RAG).
*
* Features:
* - Upload new documents (PDF/TXT/MD)
* - List attached docs with indexing status
* - Toggle mode: indexed / pinned / excluded
* - Detach (safe remove) documents
*/
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';
// ---------------------------------------------------------------------------
// Mode config
// ---------------------------------------------------------------------------
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' },
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
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);
// -----------------------------------------------------------------------
// Load documents
// -----------------------------------------------------------------------
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]);
// -----------------------------------------------------------------------
// Upload handler
// -----------------------------------------------------------------------
const handleUpload = useCallback(async (files) => {
if (!files || files.length === 0)
return;
setUploading(true);
setError(null);
for (const file of Array.from(files)) {
try {
// 1. Upload file as project item
const result = await uploadProjectItem(backendUrl, projectId, file, {
apiKey,
category: 'file',
});
if (result.ok && result.item?.id) {
// 2. Attach to persona with mode=indexed
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]);
// -----------------------------------------------------------------------
// Mode change handler
// -----------------------------------------------------------------------
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]);
// -----------------------------------------------------------------------
// Delete handler — removes document from persona AND from storage
// -----------------------------------------------------------------------
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]);
// -----------------------------------------------------------------------
// Render: document row
// -----------------------------------------------------------------------
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>);
};
// -----------------------------------------------------------------------
// Main render
// -----------------------------------------------------------------------
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>);
}