| 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
|
|
|
| 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 {
|
|
|
| }
|
| }, [ownerType, ownerClientId])
|
|
|
| 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 {
|
|
|
| }
|
| },
|
| [],
|
| )
|
|
|
| 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">📄</div>
|
|
|
| {uploading ? (
|
| <p className="doc-upload-dropzone-text">Uploading…</p>
|
| ) : (
|
| <>
|
| <p className="doc-upload-dropzone-text">
|
| Drag & 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 · {humanSize(maxTotalBytes)} total
|
| </p>
|
| </>
|
| )}
|
| </div>
|
|
|
| {error && <p className="doc-upload-error">{error}</p>}
|
|
|
| {}
|
| {docs.length > 0 && (
|
| <>
|
| <div className="doc-upload-usage">
|
| {docs.length} document{docs.length !== 1 ? 's' : ''} · {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">📄</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>
|
| )
|
| }
|
|
|