AdvisoryBuilderWorkshop / frontend /src /components /DocumentUploadSection.tsx
NeonClary's picture
Deploy tutorial feature and recent fixes
d8808e7 verified
Raw
History Blame Contribute Delete
8.19 kB
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<DocRecord[]>([])
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false)
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="doc-upload-section">
<h3>RAG Documents</h3>
<p className="field-hint" style={{ marginBottom: '0.75rem' }}>
Upload <code>.txt</code> or <code>.md</code> reference documents for {ownerLabel}.
These will be available as context during conversations.
</p>
{/* ── Drop zone ─────────────────────────────────────────────────── */}
<div
className={`doc-upload-dropzone${dragOver ? ' doc-upload-dropzone--active' : ''}`}
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
<input
ref={inputRef}
type="file"
accept={ACCEPTED}
multiple
style={{ display: 'none' }}
onChange={handleFileInput}
/>
<div className="doc-upload-dropzone-icon">&#128196;</div>
{uploading ? (
<p className="doc-upload-dropzone-text">Uploading…</p>
) : (
<>
<p className="doc-upload-dropzone-text">
Drag &amp; drop <code>.txt</code> or <code>.md</code> files here
</p>
<span className="doc-upload-dropzone-or">or</span>
<button
type="button"
className="btn btn-secondary doc-upload-browse-btn"
onClick={() => inputRef.current?.click()}
>
Choose Files
</button>
<p className="doc-upload-dropzone-limits">
Up to {humanSize(MAX_FILE_BYTES)} per file&ensp;·&ensp;{humanSize(maxTotalBytes)} total
</p>
</>
)}
</div>
{error && <p className="doc-upload-error">{error}</p>}
{/* ── Uploaded file list ────────────────────────────────────────── */}
{docs.length > 0 && (
<>
<div className="doc-upload-usage">
{docs.length} document{docs.length !== 1 ? 's' : ''}&ensp;·&ensp;{humanSize(totalBytes)} of {humanSize(maxTotalBytes)} used
</div>
<ul className="doc-upload-list">
{docs.map((d) => (
<li key={d.id} className="doc-upload-item">
<span className="doc-upload-item-icon">&#128196;</span>
<span className="doc-upload-name" title={d.filename}>
{d.filename}
</span>
<span className="doc-upload-size">{humanSize(d.size_bytes)}</span>
<button
type="button"
className="doc-upload-delete"
title="Remove document"
onClick={() => handleDelete(d.id)}
>
×
</button>
</li>
))}
</ul>
</>
)}
</div>
)
}