/* 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 (
{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á.