/* global React, Icon, MicButton */ /* AgentMode — UX tipo Claude Code / Cursor: - El usuario escribe una instrucción - Backend corre un loop de agente con tool calling - Stream SSE de eventos tipados que renderizamos como activity log - Plan progress arriba (todos), filas de tool calls + bullets de prosa - Cuando el agente llama done, aplicamos el SOP final al doc activo */ const { useState: useStateAG, useRef: useRefAG, useEffect: useEffectAG, useMemo: useMemoAG } = React; const TOOL_LABELS = { set_todos: { verb: "Actualizando plan", icon: "list" }, update_sop: { verb: "Aplicando cambios al SOP", icon: "edit" }, replace_sop: { verb: "Estructurando SOP completo", icon: "sparkles" }, add_phase: { verb: "Agregando fase", icon: "plus" }, update_step: { verb: "Editando paso", icon: "edit" }, done: { verb: "Finalizando", icon: "check" }, }; function PlanProgress({ todos }) { if (!todos || !todos.length) return null; const completed = todos.filter(t => t.status === "completed").length; return (
Plan {completed}/{todos.length} done
); } function ActivityRow({ item }) { if (item.kind === "text") { return (
{item.text}
); } if (item.kind === "tool") { const meta = TOOL_LABELS[item.name] || { verb: item.name, icon: "zap" }; return (
{item.status === "pending" ? : item.isError ? : } {meta.verb} {item.summary && · {item.summary}}
); } if (item.kind === "error") { return (
Error: {item.message}
); } if (item.kind === "done") { return (
Listo. {item.summary}
); } return null; } function summarizeToolArgs(name, args, result) { if (!args) return ""; switch (name) { case "set_todos": return (args.todos || []).length + " items"; case "replace_sop": return args.sop?.title ? `"${args.sop.title}"` : ""; case "update_sop": return Object.keys(args.patch || {}).join(", "); case "add_phase": return `Fase ${args.num}: ${args.title || ""}`; case "update_step": return `${args.phase_num}.${args.step_num}`; case "done": return ""; default: return ""; } } function AgentMode({ doc, onChange, onToast }) { const [text, setText] = useStateAG(""); const [running, setRunning] = useStateAG(false); const [todos, setTodos] = useStateAG([]); const [activity, setActivity] = useStateAG([]); // [{kind:"text"|"tool"|"done"|"error", ...}] const [error, setError] = useStateAG(""); const [provider, setProvider] = useStateAG(() => { try { return localStorage.getItem("adslaw_sop_agent_provider") || "ollama"; } catch (e) { return "ollama"; } }); const [providers, setProviders] = useStateAG([]); // catalogo desde el backend const taRef = useRefAG(null); const abortRef = useRefAG(null); const scrollRef = useRefAG(null); // Cargar catalogo de providers (sabemos cuál tiene auth válida) useEffectAG(() => { fetch("/api/agent/providers").then(r => r.json()).then(d => { setProviders(d.providers || []); // Si el provider guardado no está disponible, fallback a uno que sí const stored = provider; const list = d.providers || []; const found = list.find(p => p.id === stored); if (!found || !found.available) { const avail = list.find(p => p.available); if (avail) setProvider(avail.id); } }).catch(() => { /* sin catalog: usamos lo que sea */ }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffectAG(() => { try { localStorage.setItem("adslaw_sop_agent_provider", provider); } catch (e) {} }, [provider]); // Auto-scroll del activity log a medida que llegan eventos useEffectAG(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [activity.length, todos.length]); const pushActivity = (item) => setActivity(prev => [...prev, item]); const updateActivityById = (callId, patch) => setActivity(prev => prev.map(it => (it.kind === "tool" && it.callId === callId) ? { ...it, ...patch } : it)); const handleEvent = (event, data) => { if (event === "run_start") { setActivity([]); setTodos([]); setError(""); } else if (event === "text_delta") { pushActivity({ kind: "text", text: data.text }); } else if (event === "tool_start") { pushActivity({ kind: "tool", callId: data.callId, name: data.name, status: "pending", summary: summarizeToolArgs(data.name, data.args, null), args: data.args, }); // set_todos: refresh inmediato del plan (no esperamos al tool_end) if (data.name === "set_todos" && Array.isArray(data.args?.todos)) { setTodos(data.args.todos); } } else if (event === "tool_end") { updateActivityById(data.callId, { status: "done", isError: !!data.isError, result: data.result, }); // Si el snapshot trae todos actualizados, los reflejamos (canónico desde server) if (data.stateSnapshot?.todos) setTodos(data.stateSnapshot.todos); } else if (event === "done") { pushActivity({ kind: "done", summary: data.summary }); if (data.finalSop && onChange) { // Aplica el SOP final al doc activo preservando el id original const merged = { ...data.finalSop, id: doc?.id, createdAt: doc?.createdAt || Date.now(), updatedAt: Date.now(), }; onChange(merged); } onToast && onToast("Agente terminó ✓"); setRunning(false); setText(""); } else if (event === "error") { pushActivity({ kind: "error", message: data.message }); setError(data.message || "Error del agente"); setRunning(false); } }; const run = async () => { setError(""); if (!text.trim()) { setError("Escribe qué querés que haga el agente."); return; } setRunning(true); setActivity([]); setTodos([]); const ctrl = new AbortController(); abortRef.current = ctrl; try { const resp = await fetch("/api/agent/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_message: text.trim(), current_sop: doc || null, provider, }), signal: ctrl.signal, }); if (!resp.ok) { const t = await resp.text().catch(() => ""); throw new Error(`Backend ${resp.status}: ${t.slice(0, 200)}`); } const reader = resp.body.getReader(); const decoder = new TextDecoder("utf-8"); let buf = ""; // Parser SSE: separamos por `\n\n`, cada bloque tiene `event:` y `data:` while (true) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf("\n\n")) >= 0) { const chunk = buf.slice(0, idx); buf = buf.slice(idx + 2); if (!chunk.trim()) continue; let evName = "message"; let evData = ""; for (const line of chunk.split("\n")) { if (line.startsWith("event:")) evName = line.slice(6).trim(); else if (line.startsWith("data:")) evData += line.slice(5).trim(); } if (!evData) continue; let parsed; try { parsed = JSON.parse(evData); } catch { parsed = { raw: evData }; } handleEvent(evName, parsed); } } } catch (e) { if (e.name !== "AbortError") { setError(e.message || "Error de conexión"); pushActivity({ kind: "error", message: e.message || "Conexión interrumpida" }); } setRunning(false); } }; const stop = () => { if (abortRef.current) abortRef.current.abort(); setRunning(false); pushActivity({ kind: "error", message: "Run cancelado por el usuario." }); }; const onVoice = (t) => { setText(prev => { const sep = prev && !/\s$/.test(prev) ? " " : ""; return (prev + sep + t).trim() + " "; }); }; return (

Agente

{providers.length > 0 && (
{providers.map(p => ( ))}
)}

Decile en lenguaje natural qué querés hacer con este SOP. El agente arma un plan, ejecuta paso a paso y te muestra cada acción en vivo.

{/* Activity log + plan (solo cuando hay algo) */} {(running || todos.length > 0 || activity.length > 0) && (
{todos.length > 0 && } {activity.length > 0 && (
Activity ({activity.filter(a => a.kind === "tool").length} steps)
{activity.map((item, i) => )} {running && (
Pensando…
)}
)}
)} {/* Input */}