/* 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
{todos.map((t, i) => (
-
{t.status === "completed" ?
: t.status === "in_progress" ?
: }
{t.text}
))}
);
}
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 */}
{!running && activity.length === 0 && (
Cómo funciona
- El agente primero arma un plan con todos visibles.
- Antes de cada acción te dice qué va a hacer.
- Ejecuta tools (estructurar, editar, agregar fase, etc.) y vos las ves en vivo.
- Al terminar aplica los cambios al SOP de la derecha.
)}
);
}
window.AgentMode = AgentMode;