/* global React, Icon */ /* AIActivity — panel flotante que escucha los CustomEvents del shim (sop-creator:ai-start/chunk/end/error) y muestra en vivo qué está haciendo la IA en modos Simple y Avanzado. En el modo Agente existe su propio activity log (eventos SSE con tool calls). Acá emulamos algo parecido pero a partir del único señal que tenemos en Simple/Avanzado: tokens fluyendo de /api/complete. Estrategia de render para que no parpadee: - Buffer rolling de ACUMULADO (no del delta). El stream del modelo emite de a 1-5 chars por chunk; si pintamos solo el delta, el preview parece "saltar". Si pintamos los últimos N del acumulado, queda estable. - rAF throttle: agrupamos chunks dentro del mismo frame en un solo render. Sin esto, React mete decenas de renders por segundo cuando el modelo escupe tokens rápido. */ const { useState: useStateAI, useEffect: useEffectAI, useRef: useRefAI } = React; const PREVIEW_TAIL = 110; // cuántos chars del acumulado mostrar en el stream const AUTO_HIDE_DONE_MS = 6000; const AUTO_HIDE_ERROR_MS = 10000; function AIActivity() { const [state, setState] = useStateAI("idle"); // idle | running | done | error const [chars, setChars] = useStateAI(0); const [preview, setPreview] = useStateAI(""); const [provider, setProvider] = useStateAI(""); const [startedAt, setStartedAt] = useStateAI(null); const [elapsedMs, setElapsedMs] = useStateAI(0); const [error, setError] = useStateAI(""); const [phase, setPhase] = useStateAI("Pensando…"); const tickRef = useRefAI(null); const hideTimerRef = useRefAI(null); // Buffer de últimos chunks acumulados. Lo mantengo en ref para evitar // re-renderer en cada chunk; el rAF copia esto al state a 60fps máximo. const accRef = useRefAI(""); const pendingFrameRef = useRefAI(null); // El "phase" es heurístico: en Simple/Avanzado la respuesta es UN solo // JSON, así que solo distinguimos fases por bytes recibidos. Feedback // aproximado, no telemetría real. useEffectAI(() => { if (state !== "running") return; if (chars === 0) setPhase("Pensando…"); else if (chars < 200) setPhase("Generando estructura…"); else if (chars < 1500) setPhase("Escribiendo fases…"); else setPhase("Finalizando…"); }, [state, chars]); // Tick del elapsed (200ms es más que suficiente para mostrar segundos) useEffectAI(() => { if (state !== "running" || !startedAt) { if (tickRef.current) { clearInterval(tickRef.current); tickRef.current = null; } return; } tickRef.current = setInterval(() => { setElapsedMs(Date.now() - startedAt); }, 200); return () => { if (tickRef.current) clearInterval(tickRef.current); }; }, [state, startedAt]); useEffectAI(() => { const flush = () => { pendingFrameRef.current = null; const acc = accRef.current; setChars(acc.length); // El acumulado puede tener saltos de línea / JSON crudo; lo aplanamos a // una sola línea para el preview compacto. Mantenemos los últimos N chars. const flat = acc.replace(/\s+/g, " "); setPreview(flat.length > PREVIEW_TAIL ? "…" + flat.slice(-PREVIEW_TAIL) : flat); }; const schedule = () => { if (pendingFrameRef.current != null) return; pendingFrameRef.current = requestAnimationFrame(flush); }; const onStart = (e) => { const d = e.detail || {}; if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; } if (pendingFrameRef.current != null) { cancelAnimationFrame(pendingFrameRef.current); pendingFrameRef.current = null; } accRef.current = ""; setState("running"); setChars(0); setPreview(""); setError(""); setProvider(d.provider || ""); setStartedAt(d.startedAt || Date.now()); setElapsedMs(0); setPhase("Pensando…"); }; const onChunk = (e) => { const d = e.detail || {}; // Preferimos el acumulado del shim (verdad canónica); si no, parcheamos // localmente concatenando el delta. Esto da un preview estable que no // parpadea con cada token. if (typeof d.accumulated === "string") accRef.current = d.accumulated; else if (typeof d.latest === "string") accRef.current += d.latest; schedule(); }; const onEnd = (e) => { const d = e.detail || {}; // Forzar flush final antes de cambiar de estado para no quedarnos con un // preview a medio actualizar. if (pendingFrameRef.current != null) { cancelAnimationFrame(pendingFrameRef.current); pendingFrameRef.current = null; } const acc = accRef.current; setChars(typeof d.chars === "number" ? d.chars : acc.length); setState("done"); setElapsedMs(d.durationMs || 0); hideTimerRef.current = setTimeout(() => setState("idle"), AUTO_HIDE_DONE_MS); }; const onError = (e) => { const d = e.detail || {}; setState("error"); setError(d.message || "Error desconocido"); hideTimerRef.current = setTimeout(() => setState("idle"), AUTO_HIDE_ERROR_MS); }; window.addEventListener("sop-creator:ai-start", onStart); window.addEventListener("sop-creator:ai-chunk", onChunk); window.addEventListener("sop-creator:ai-end", onEnd); window.addEventListener("sop-creator:ai-error", onError); return () => { window.removeEventListener("sop-creator:ai-start", onStart); window.removeEventListener("sop-creator:ai-chunk", onChunk); window.removeEventListener("sop-creator:ai-end", onEnd); window.removeEventListener("sop-creator:ai-error", onError); if (tickRef.current) clearInterval(tickRef.current); if (hideTimerRef.current) clearTimeout(hideTimerRef.current); if (pendingFrameRef.current != null) cancelAnimationFrame(pendingFrameRef.current); }; }, []); if (state === "idle") return null; const elapsedSec = (elapsedMs / 1000).toFixed(1); const providerLabel = provider === "codex" ? "Codex" : provider === "ollama" ? "Ollama" : provider || "IA"; return (
{state === "running" && } {state === "done" && } {state === "error" && } {state === "running" && phase} {state === "done" && "Listo"} {state === "error" && "Error"} {providerLabel} · {chars.toLocaleString("es-ES")} chars · {elapsedSec}s
{state === "running" && preview && (
{preview}
)} {state === "error" && error && (
{error}
)}
); } window.AIActivity = AIActivity;