/* Knowledge — biblioteca para subir libros / artículos / apuntes que el coach * usa como referencia al generar planes. * * Backend: /knowledge/documents (POST upload multipart, GET list, DELETE). * El parser extrae texto (PDF / Markdown / .txt / EPUB), lo parte en chunks y * los embebe contra el LLM activo. Cuando el coach genera un plan, el módulo * `plan_evidence.build_references_block` consulta el vector store y le pasa * los hits relevantes al LLM con instrucción de citar por título. */ const SUPPORTED_EXT = [".pdf", ".md", ".markdown", ".txt", ".epub"]; const KIND_OPTIONS = [ { value: "book", label: "Libro" }, { value: "article", label: "Artículo / paper" }, { value: "notes", label: "Apuntes" }, ]; /* ── Global upload queue singleton ── * * Vive fuera de React así sobrevive a navegaciones. Cuando el user sale de * Biblioteca durante un upload, el drain loop sigue corriendo (la promise * del fetch no depende del componente). Cuando vuelve, se re-suscribe y ve * el estado actual. * * API: * - getUploadQueue() → singleton. * - queue.subscribe(listener) → re-render trigger; devuelve unsub. * - queue.snapshot() → array inmutable de items para render. * - queue.enqueue(files, kind) → encolar varios + arrancar drain. * - queue.removeItem(id) → remueve done/error/pending. * - queue.clear() → limpia los terminales (no in-flight). * - queue.setOnDocComplete(fn) → fn() corre tras cada upload exitoso. * * Item shape: {id, file, title, kind, status, error?, startedAt?, durationMs?}. */ function getUploadQueue() { if (typeof window === "undefined") return null; if (window.__af_uploadQueue__) return window.__af_uploadQueue__; const state = { items: [], listeners: new Set(), draining: false, onDocComplete: null, }; const notify = () => { for (const fn of state.listeners) { try { fn(); } catch (_) { /* listener crash no debe matar el queue */ } } }; const updateItem = (id, patch) => { state.items = state.items.map((x) => (x.id === id ? { ...x, ...patch } : x)); notify(); }; const drainNext = async () => { if (state.draining) return; const next = state.items.find((q) => q.status === "pending"); if (!next) return; state.draining = true; notify(); const startedAt = Date.now(); updateItem(next.id, { status: "uploading", startedAt }); try { if (window.API && window.API.demoMode && window.API.demoMode()) { await new Promise((r) => setTimeout(r, 1200)); } else { await window.API.uploadDocument({ file: next.file, title: next.title.trim() || next.file.name, kind: next.kind }); } updateItem(next.id, { status: "done", durationMs: Date.now() - startedAt }); if (typeof state.onDocComplete === "function") { try { await state.onDocComplete(); } catch (_) {} } } catch (e) { let msg; if (e.status === 412) msg = "Falta configurar un LLM en Configuración → LLM."; else if (e.status === 413) msg = "Archivo demasiado grande (máx 50 MB)."; else if (e.status === 415) msg = "Formato no soportado."; else msg = e.detail?.detail || e.message || "No se pudo subir."; updateItem(next.id, { status: "error", error: msg, durationMs: Date.now() - startedAt }); } finally { state.draining = false; notify(); // Cola en serie: si quedan pendientes, encadenamos en el próximo tick. if (state.items.some((q) => q.status === "pending")) { setTimeout(drainNext, 50); } } }; const queue = { subscribe(fn) { state.listeners.add(fn); return () => state.listeners.delete(fn); }, snapshot() { return state.items.slice(); }, enqueue(files, kind) { if (!files || files.length === 0) return; const additions = []; for (const f of files) { const lc = f.name.toLowerCase(); if (!SUPPORTED_EXT.some((ext) => lc.endsWith(ext))) { additions.push({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file: f, title: f.name.replace(/\.[^.]+$/, ""), kind, status: "error", error: `Formato no soportado: ${f.name}`, }); continue; } additions.push({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file: f, title: f.name.replace(/\.[^.]+$/, ""), kind, status: "pending", }); } state.items = [...state.items, ...additions]; notify(); drainNext(); }, removeItem(id) { // Nunca remover un upload en curso — esperá a que termine. state.items = state.items.filter((x) => !(x.id === id && x.status !== "uploading")); notify(); }, clear() { state.items = state.items.filter((x) => x.status === "uploading" || x.status === "pending"); notify(); }, setOnDocComplete(fn) { state.onDocComplete = fn; }, isBusy() { return state.items.some((x) => x.status === "uploading" || x.status === "pending"); }, activeCount() { return state.items.filter((x) => x.status === "uploading" || x.status === "pending").length; }, }; window.__af_uploadQueue__ = queue; return queue; } /* React hook que sigue al singleton: re-renderiza cuando cambia el estado. */ function useUploadQueue() { const queue = React.useMemo(() => getUploadQueue(), []); const [, setTick] = React.useState(0); React.useEffect(() => { if (!queue) return undefined; return queue.subscribe(() => setTick((t) => t + 1)); }, [queue]); return queue; } const Knowledge = ({ inDemo }) => { const [docs, setDocs] = React.useState(null); // null=loading, []=empty const [err, setErr] = React.useState(null); const refresh = React.useCallback(async () => { if (inDemo) { // Demo no toca el backend — mostramos un par de ejemplos visualmente. setDocs([ { id: "book:demo_daniels", title: "Daniels Running Formula (demo)", kind: "book", chunks: 142, bytes_size: 4200000, uploaded_at: new Date().toISOString() }, ]); return; } setErr(null); try { const res = await API.listDocuments(); const list = Array.isArray(res?.documents) ? res.documents : []; setDocs(list); } catch (e) { if (e.status === 401 || e.status === 403) return; setErr(e.message || "No se pudo cargar la biblioteca."); setDocs([]); } }, [inDemo]); React.useEffect(() => { refresh(); }, [refresh]); // Cuando un upload termina, el singleton dispara `onDocComplete` para que // refresquemos la lista. Re-registramos el callback en cada mount porque // la closure captura el `refresh` del render actual. React.useEffect(() => { const queue = getUploadQueue(); if (!queue) return undefined; queue.setOnDocComplete(refresh); return () => { queue.setOnDocComplete(null); }; }, [refresh]); return (
BIBLIOTECA

Conocimiento que alimenta tu coach.

Subí libros (Daniels, Pfitzinger, Friel…), artículos o tus propios apuntes en PDF, Markdown, .txt o EPUB. Cuando el coach genere un plan, va a buscar fragmentos relevantes y los va a citar por título en la justificación de cada sesión.
{err && (
Error: {err}
)}
); }; /* ───────── UploadCard ───────── * * Lee de `getUploadQueue()` (singleton fuera de React). Si el usuario navega * a Hoy/Métricas/etc. mientras un libro se procesa, vuelve a Biblioteca y la * fila sigue ahí con su estado actual (`uploading · 47s`). El tick de un * intervalo refresca el contador mientras haya un archivo en proceso. */ const UploadCard = ({ inDemo }) => { const queue = useUploadQueue(); const items = queue ? queue.snapshot() : []; const [kind, setKind] = React.useState("book"); const fileRef = React.useRef(null); // Tick para que el contador "procesando · Ns" avance sin esperar a un // cambio de estado del singleton. Lo paramos cuando no hay nada en curso. const [, setTick] = React.useState(0); const isBusy = queue ? queue.isBusy() : false; React.useEffect(() => { if (!isBusy) return undefined; const id = setInterval(() => setTick((t) => t + 1), 500); return () => clearInterval(id); }, [isBusy]); const enqueue = (filesList) => { if (!queue) return; queue.enqueue(Array.from(filesList || []), kind); if (fileRef.current) fileRef.current.value = ""; }; const inFlight = items.find((q) => q.status === "uploading"); const pendingCount = items.filter((q) => q.status === "pending").length; const doneCount = items.filter((q) => q.status === "done").length; const errorCount = items.filter((q) => q.status === "error").length; return (
SUBIR DOCUMENTOS
{(doneCount > 0 || errorCount > 0) && !inFlight && pendingCount === 0 && queue && ( queue.clear()}> Limpiar cola )}
ARCHIVOS (uno o varios)
enqueue(e.target.files)} style={{ width: "100%", padding: "8px 10px", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-2)", fontSize: 12, color: "var(--text)", }} />
TIPO
{items.length === 0 ? "elegí archivos" : `${doneCount}/${items.length} listos`}
{items.length > 0 && (
{items.map((it) => ( queue && queue.removeItem(it.id)} /> ))}
)}
Formatos soportados: {SUPPORTED_EXT.join(", ")}. Tope: 50 MB cada uno. Procesamos un archivo a la vez para no saturar el rate-limit del LLM. Un libro de ~250 chunks puede tardar 30–90 s mientras se generan los embeddings. Podés cambiar de pantalla durante el proceso — al volver, la cola sigue acá.
); }; /* UploadQueueRow — fila por archivo: status + barra animada cuando upload */ const UploadQueueRow = ({ item, elapsedSec, onRemove }) => { const sizeMb = item.file ? (item.file.size / (1024 * 1024)).toFixed(1) : "?"; const statusColor = { pending: "var(--text-3)", uploading: "var(--info)", done: "var(--good)", error: "var(--bad)", }[item.status] || "var(--text-3)"; const statusLabel = { pending: "en cola", uploading: elapsedSec != null ? `procesando · ${elapsedSec}s` : "procesando", done: item.durationMs ? `listo · ${(item.durationMs / 1000).toFixed(1)}s` : "listo", error: "error", }[item.status] || item.status; return (
{item.file?.name || item.title}
{sizeMb} MB · {item.kind} {item.error && · {item.error}}
{item.status === "uploading" && (
)}
{statusLabel} {item.status !== "uploading" && ( {item.status === "pending" ? "Cancelar" : "✕"} )}
); }; /* ───────── DocumentsList ───────── */ const DocumentsList = ({ docs, inDemo, onChanged }) => { if (docs === null) { return (
cargando biblioteca…
); } if (docs.length === 0) { return (
Sin documentos indexados todavía.
Subí tu primer libro arriba — el coach lo va a citar en el próximo plan.
); } return (
DOCUMENTOS INDEXADOS
{docs.length} doc{docs.length === 1 ? "" : "s"} · {docs.reduce((s, d) => s + (d.chunks || 0), 0)} chunks totales
{docs.map((d, i) => ( ))}
); }; const DocumentRow = ({ doc, inDemo, onChanged, last }) => { const [deleting, setDeleting] = React.useState(false); const [err, setErr] = React.useState(null); const sizeMb = doc.bytes_size != null ? (doc.bytes_size / (1024 * 1024)).toFixed(1) : null; const uploaded = doc.uploaded_at ? doc.uploaded_at.slice(0, 10) : "—"; const remove = async () => { if (!confirm(`¿Eliminar "${doc.title}"? Esto borra el manifiesto y todos los chunks indexados.`)) return; setDeleting(true); setErr(null); try { if (inDemo) { await new Promise((r) => setTimeout(r, 400)); } else { await API.deleteDocument(doc.id); } await onChanged(); } catch (e) { setErr(e.message || "No se pudo eliminar."); setDeleting(false); } }; return (
{doc.title}
{doc.id} · {doc.kind}
{err &&
{err}
}
{doc.chunks ?? 0} chunks {uploaded}{sizeMb ? ` · ${sizeMb} MB` : ""} {deleting ? "…" : "Eliminar"}
); }; window.Knowledge = Knowledge;