import { useCallback, useEffect, useRef, useState } from 'react' import { apiFetch } from '../api' type OwnerType = 'persona' | 'advisor_panel' type DocRecord = { id: string filename: string size_bytes: number created_at: string | null } interface Props { ownerType: OwnerType ownerClientId: string | null ownerLabel: string maxTotalBytes: number /** Called once to auto-save the draft and obtain an ownerClientId when one doesn't exist yet. */ onEnsureOwner?: () => string | null } const MAX_FILE_BYTES = 1 * 1024 * 1024 * 1024 const ACCEPTED = '.txt,.md' function humanSize(b: number): string { for (const unit of ['bytes', 'KB', 'MB', 'GB']) { if (Math.abs(b) < 1024) return `${b.toFixed(unit === 'bytes' ? 0 : 1)} ${unit}` b /= 1024 } return `${b.toFixed(1)} TB` } export function DocumentUploadSection({ ownerType, ownerClientId, ownerLabel, maxTotalBytes, onEnsureOwner, }: Props) { const [docs, setDocs] = useState([]) const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) const [dragOver, setDragOver] = useState(false) const inputRef = useRef(null) const resolvedIdRef = useRef(ownerClientId) resolvedIdRef.current = ownerClientId const totalBytes = docs.reduce((s, d) => s + d.size_bytes, 0) const ensureId = useCallback((): string | null => { if (resolvedIdRef.current) return resolvedIdRef.current if (onEnsureOwner) { const id = onEnsureOwner() if (id) resolvedIdRef.current = id return id } return null }, [onEnsureOwner]) const loadDocs = useCallback(async () => { const id = resolvedIdRef.current if (!id) { setDocs([]) return } try { const r = await apiFetch( `/api/users/me/documents?owner_type=${ownerType}&owner_client_id=${encodeURIComponent(id)}`, ) if (r.ok) { const list: DocRecord[] = await r.json() setDocs(list) } } catch { /* silent */ } }, [ownerType, ownerClientId]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { void loadDocs() }, [loadDocs]) const doUpload = useCallback( async (files: FileList | File[]) => { setError(null) const ownerId = ensureId() if (!ownerId) { setError('Could not save draft. Please fill in at least a name, then try again.') return } const fileArray = Array.from(files) for (const file of fileArray) { const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase() if (ext !== '.txt' && ext !== '.md') { setError(`"${file.name}" is not a supported file type. Only .txt and .md files are accepted.`) return } if (file.size > MAX_FILE_BYTES) { setError(`"${file.name}" is too large (${humanSize(file.size)}). Maximum is ${humanSize(MAX_FILE_BYTES)} per file.`) return } if (totalBytes + file.size > maxTotalBytes) { setError( `Adding "${file.name}" would exceed the ${humanSize(maxTotalBytes)} limit for ${ownerLabel} (currently using ${humanSize(totalBytes)}).`, ) return } } setUploading(true) try { for (const file of fileArray) { const fd = new FormData() fd.append('owner_type', ownerType) fd.append('owner_client_id', ownerId) fd.append('file', file) const r = await apiFetch('/api/users/me/documents', { method: 'POST', body: fd, }) if (!r.ok) { const body = await r.json().catch(() => null) throw new Error(body?.detail || `Upload failed (${r.status})`) } } await loadDocs() } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Upload failed') } finally { setUploading(false) if (inputRef.current) inputRef.current.value = '' } }, [ownerType, ensureId, ownerLabel, totalBytes, maxTotalBytes, loadDocs], ) const handleFileInput = useCallback( (e: React.ChangeEvent) => { if (e.target.files?.length) void doUpload(e.target.files) }, [doUpload], ) const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault() setDragOver(false) if (e.dataTransfer.files?.length) void doUpload(e.dataTransfer.files) }, [doUpload], ) const handleDelete = useCallback( async (docId: string) => { try { const r = await apiFetch(`/api/users/me/documents/${docId}`, { method: 'DELETE' }) if (r.ok) { setDocs((prev) => prev.filter((d) => d.id !== docId)) setError(null) } } catch { /* silent */ } }, [], ) return (

RAG Documents

Upload .txt or .md reference documents for {ownerLabel}. These will be available as context during conversations.

{/* ── Drop zone ─────────────────────────────────────────────────── */}
{ e.preventDefault() setDragOver(true) }} onDragLeave={() => setDragOver(false)} onDrop={handleDrop} >
📄
{uploading ? (

Uploading…

) : ( <>

Drag & drop .txt or .md files here

or

Up to {humanSize(MAX_FILE_BYTES)} per file · {humanSize(maxTotalBytes)} total

)}
{error &&

{error}

} {/* ── Uploaded file list ────────────────────────────────────────── */} {docs.length > 0 && ( <>
{docs.length} document{docs.length !== 1 ? 's' : ''} · {humanSize(totalBytes)} of {humanSize(maxTotalBytes)} used
    {docs.map((d) => (
  • 📄 {d.filename} {humanSize(d.size_bytes)}
  • ))}
)}
) }