/* Coach — briefing diario + chat con agentes
*
* Modelo: 3 agentes (coach / nutri / médico) corren en paralelo cada mañana
* sobre los datos de las últimas 24-72h, emiten una lectura individual y se
* fusionan en un plan ajustado. El usuario aprueba, ajusta o rechaza. El chat
* queda como acción secundaria (drawer) y SÍ pega contra POST /agents/ask.
*/
const PANEL_AGENTS = {
coach: { label: "Coach", role: "Carga & entrenamiento", icon: "coach", color: "var(--accent)" },
nutri: { label: "Nutricionista", role: "Energía & composición", icon: "apple", color: "var(--good)" },
medical: { label: "Médico", role: "Lectura clínica", icon: "medical", color: "var(--info)" },
};
const TODAY_READINGS = {
coach: {
status: "warn",
headline: "Carga aguda alta · ratio ACWR 1.42",
findings: [
{ tone: "BIEN", text: "Volumen semanal en target (47 km vs 50)." },
{ tone: "ATENCIÓN", text: "ACWR 1.42 — riesgo elevado de lesión si sumás carga." },
{ tone: "MEJORA", text: "Bajar el largo del sábado de 22 a 16 km, mover series intensas a el martes." },
],
metric: { label: "ACWR", value: "1.42", target: "0.8-1.3" },
confidence: 0.86,
cost: 0.018,
},
nutri: {
status: "good",
headline: "Energía suficiente · proteína 1.6 g/kg",
findings: [
{ tone: "BIEN", text: "EA disponible 38 kcal/kg fat-free mass — sano." },
{ tone: "BIEN", text: "Proteína distribuida en 4 tomas, target alcanzado." },
{ tone: "MEJORA", text: "Subir carbos en día de largo: +60-90 g pre-entreno." },
],
metric: { label: "EA", value: "38", target: "≥30 kcal/kg" },
confidence: 0.79,
cost: 0.014,
},
medical: {
status: "warn",
headline: "HRV en tendencia descendente 7d",
findings: [
{ tone: "ATENCIÓN", text: "HRV pasó de 58 a 47 ms en 7 días (-19%)." },
{ tone: "ATENCIÓN", text: "FC reposo subió 4 ppm vs baseline." },
{ tone: "BIEN", text: "Sin signos de infección o sobreentreno clínico todavía." },
{ tone: "MEJORA", text: "Considerar 1-2 días de descarga + revisar sueño (5h32 promedio)." },
],
metric: { label: "HRV 7d", value: "47", target: "≥55 ms" },
confidence: 0.71,
cost: 0.022,
},
};
const REGEN_HISTORY = [
{ date: "Hoy 06:42", trigger: "Auto · sync Garmin", summary: "Bajada de HRV detectada → propuesto día de descarga", state: "pending" },
{ date: "Ayer 06:38", trigger: "Auto", summary: "Sin cambios — dentro de tolerancia", state: "skipped" },
{ date: "Vie 18:10", trigger: "Manual · sentís fatiga", summary: "Sustituido tempo por rodaje suave", state: "applied" },
{ date: "Mié 06:35", trigger: "Auto", summary: "Aumento de volumen 5% para semana 3", state: "applied" },
{ date: "Lun 12:04", trigger: "Manual · viaje", summary: "Sesión movida de jueves a miércoles", state: "applied" },
];
const PLAN_DIFF = {
date: "Sábado 02 may",
original: {
workout: { title: "Largo Z2", detail: "22 km · 6:15/km · ~2h17", load: 145, kcal: 1640 },
nutri: { title: "Carga normal", detail: "Carbs 5.5 g/kg · 2,650 kcal", load: 0 },
notes: "Última sesión larga del bloque base.",
},
proposed: {
workout: { title: "Rodaje Z2 + movilidad", detail: "16 km · 6:30/km · ~1h44", load: 102, kcal: 1180 },
nutri: { title: "Carbs ↑ 60-90 g pre", detail: "Carbs 5.8 g/kg · 2,560 kcal", load: 0 },
notes: "Bajar volumen 27% por HRV descendente. Si HRV recupera ≥55 ms, retomar largo el sábado próximo.",
},
changes: [
{ agent: "coach", change: "−6 km (−27% volumen)" },
{ agent: "coach", change: "Pace ajustado +15 s/km" },
{ agent: "nutri", change: "+60-90 g carbs pre" },
{ agent: "medical", change: "Flag de descarga sugerido" },
],
};
const Agents = ({ density, dataState, showDisclaimer }) => {
const inDemo = API.demoMode();
const [view, setView] = React.useState("today");
const [chatOpen, setChatOpen] = React.useState(false);
// LLM configurado o no — sólo bloquea fuera de demo.
const [llmConfigured, setLlmConfigured] = React.useState(true);
React.useEffect(() => {
if (inDemo) { setLlmConfigured(true); return; }
API.getLLMConfig().then(() => setLlmConfigured(true)).catch((e) => {
if (e.status === 404 || e.status === 412 || /not configured|missing/i.test(e.message)) setLlmConfigured(false);
});
}, [inDemo]);
if (dataState === "empty") {
return (
Conectar Garmin}
/>
);
}
if (!llmConfigured) {
return (
{ window.dispatchEvent(new CustomEvent("af-route", { detail: "settings" })); }}>Configurar LLM}
/>
);
}
return (
{view === "today" && (
inDemo
?
setChatOpen(true)} />
: setChatOpen(true)} onLlmMissing={() => setLlmConfigured(false)} />
)}
{view === "history" && }
{view === "chat" && }
{chatOpen && setChatOpen(false)} />}
);
};
/* ───────── Vista LIVE (cableada al backend) ───────── */
const REFRESH_PHASES = [
"Consultando coach…",
"Consultando nutricionista…",
"Consultando médico…",
"Consolidando plan…",
];
const BriefingViewLive = ({ showDisclaimer, onChat, onLlmMissing }) => {
/* Estados:
* loading: fetch inicial de /agents/briefing/latest
* refreshing: POST /agents/briefing/refresh corriendo (mostrar fases)
* briefing: el briefing actual (o null si 404)
* error: error duro distinto de 404/412 (red u otro 5xx)
*/
const [loading, setLoading] = React.useState(true);
const [refreshing, setRefreshing] = React.useState(false);
const [phaseIdx, setPhaseIdx] = React.useState(0);
const [briefing, setBriefing] = React.useState(null);
const [error, setError] = React.useState(null);
// Carga inicial
React.useEffect(() => {
let alive = true;
(async () => {
try {
const res = await API.briefingLatest();
if (!alive) return;
setBriefing(res); // null si 404
setError(null);
} catch (e) {
if (!alive) return;
if (e.status === 401 || e.status === 403) return;
setError(e.message);
} finally {
if (alive) setLoading(false);
}
})();
return () => { alive = false; };
}, []);
// Animación de fases mientras refrescamos.
React.useEffect(() => {
if (!refreshing) { setPhaseIdx(0); return; }
const t = setInterval(() => {
setPhaseIdx(i => Math.min(i + 1, REFRESH_PHASES.length - 1));
}, 4500);
return () => clearInterval(t);
}, [refreshing]);
const triggerRefresh = async () => {
setRefreshing(true);
setPhaseIdx(0);
setError(null);
try {
const res = await API.briefingRefresh();
setBriefing(res);
} catch (e) {
if (e.llmMissing) {
// Backend dice 412 — la vista padre redirige a empty state de LLM.
onLlmMissing && onLlmMissing();
return;
}
setError(e.message);
} finally {
setRefreshing(false);
}
};
if (loading) {
return (
Cargando briefing…
);
}
if (error && !briefing) {
return (
NO PUDIMOS CARGAR EL BRIEFING
{error}
Reintentar
);
}
if (refreshing) {
return ;
}
if (!briefing) {
return ;
}
return (
);
};
const BriefingEmptyState = ({ onGenerate }) => (
}>Generar briefing del día}
/>
);
const BriefingRefreshing = ({ phaseIdx }) => (
Generando briefing del día…
{REFRESH_PHASES.map((p, i) => {
const done = i < phaseIdx;
const active = i === phaseIdx;
return (
{done ? "✓" : active ? "●" : "·"}
{p}
);
})}
~15-30s · podés cerrar y volver, no se cancela
);
const BriefingDisplay = ({ briefing, onRegenerate, onChat, showDisclaimer }) => {
const readings = Array.isArray(briefing.readings) ? briefing.readings : [];
const generatedLabel = formatBriefingDate(briefing.generated_at);
const hasMedical = readings.some(r => r.agent === "medical");
return (
<>
BRIEFING DEL DÍA{generatedLabel ? ` · ${generatedLabel.toUpperCase()}` : ""}
Lectura conjunta de los 3 agentes.
{readings.length} lectura{readings.length === 1 ? "" : "s"}
{briefing.id ? ` · id ${String(briefing.id).slice(0, 8)}` : ""}
}>
Regenerar
}>
Pedir ajustes (chat)
{readings.length > 0 && (
{readings.map((r, i) => (
))}
)}
{briefing.plan_proposed && (
PLAN PROPUESTO
SÍNTESIS CONJUNTA
)}
{showDisclaimer && hasMedical && (
Esto no es consejo médico. El módulo médico interpreta tendencias de tus datos. No reemplaza consulta clínica ni diagnóstico. Ante síntomas, consultá un profesional.
)}
>
);
};
const BriefingReadingCard = ({ reading, showDisclaimer }) => {
const agentKey = reading.agent || "coach";
const agent = PANEL_AGENTS[agentKey] || { label: agentKey, role: "", icon: "bot", color: "var(--accent)" };
const isMedical = agentKey === "medical";
const sources = Array.isArray(reading.sources) ? reading.sources : [];
return (
{agent.label}
{agent.role}
{isMedical && showDisclaimer && (
No es consejo médico. Tendencias, no diagnóstico.
)}
{reading.title && (
{reading.title}
)}
{reading.body && (
{reading.body}
)}
{sources.length > 0 && (
{sources.map((s, i) => (
{s}
))}
)}
);
};
/* Mini-parser de markdown (titulos h1-h3, listas con `-` y `*`, negritas con
* `**...**`, italics con `_..._`). Suficiente para lo que escupen los LLMs en
* el plan_proposed; si más adelante necesitamos más cosas, swap a `marked`
* desde CDN. */
const MarkdownRender = ({ text }) => {
const blocks = parseMarkdownBlocks(text || "");
return (
{blocks.map((b, i) => {
if (b.type === "h1") return
{renderInline(b.text)} ;
if (b.type === "h2") return
{renderInline(b.text)} ;
if (b.type === "h3") return
{renderInline(b.text)} ;
if (b.type === "ul") return (
{b.items.map((li, j) => {renderInline(li)} )}
);
if (b.type === "ol") return (
{b.items.map((li, j) => {renderInline(li)} )}
);
if (b.type === "p") return
{renderInline(b.text)}
;
return null;
})}
);
};
/* Quita el code-fence con el que algunos modelos envuelven toda la respuesta
* ("```markdown\n...\n```"). Si la primera línea no-vacía abre un fence, lo
* removemos junto con el fence final. Se aplica también dentro del parser de
* planes (utils-plan-parser.jsx) para que el calendario detecte las semanas. */
function stripWrappingCodeFence(md) {
if (!md) return md;
const text = String(md).replace(/\r\n/g, "\n");
const m = text.match(/^\s*```[a-zA-Z]*\s*\n([\s\S]*?)\n```\s*$/);
if (m) return m[1];
// También formato sin newline final (algunos modelos cierran sin salto):
const m2 = text.match(/^\s*```[a-zA-Z]*\s*\n([\s\S]*?)```\s*$/);
if (m2) return m2[1];
return text;
}
function parseMarkdownBlocks(md) {
const lines = stripWrappingCodeFence(md).split("\n");
const blocks = [];
let para = [];
let list = null; // {type: "ul"|"ol", items: []}
const flushPara = () => { if (para.length) { blocks.push({ type: "p", text: para.join(" ") }); para = []; } };
const flushList = () => { if (list) { blocks.push(list); list = null; } };
for (const raw of lines) {
const line = raw.trim();
if (!line) { flushPara(); flushList(); continue; }
if (line.startsWith("#### ")) { flushPara(); flushList(); blocks.push({ type: "h3", text: line.slice(5) }); continue; }
if (line.startsWith("### ")) { flushPara(); flushList(); blocks.push({ type: "h3", text: line.slice(4) }); continue; }
if (line.startsWith("## ")) { flushPara(); flushList(); blocks.push({ type: "h2", text: line.slice(3) }); continue; }
if (line.startsWith("# ")) { flushPara(); flushList(); blocks.push({ type: "h1", text: line.slice(2) }); continue; }
const liUl = line.match(/^[-*]\s+(.+)$/);
if (liUl) {
flushPara();
if (!list || list.type !== "ul") { flushList(); list = { type: "ul", items: [] }; }
list.items.push(liUl[1]);
continue;
}
const liOl = line.match(/^\d+\.\s+(.+)$/);
if (liOl) {
flushPara();
if (!list || list.type !== "ol") { flushList(); list = { type: "ol", items: [] }; }
list.items.push(liOl[1]);
continue;
}
flushList();
para.push(line);
}
flushPara(); flushList();
return blocks;
}
// Expose so utils-plan-parser.jsx can reuse the same fence-stripping logic.
window.stripWrappingCodeFence = stripWrappingCodeFence;
function renderInline(text) {
// Escape HTML; soporte simple para **bold** y _italic_.
// Devolvemos un array de fragments React seguros.
const parts = [];
const re = /(\*\*([^*]+)\*\*|_([^_]+)_)/g;
let last = 0; let m; let key = 0;
while ((m = re.exec(text)) !== null) {
if (m.index > last) parts.push(text.slice(last, m.index));
if (m[2] != null) parts.push({m[2]} );
else if (m[3] != null) parts.push({m[3]} );
last = m.index + m[0].length;
}
if (last < text.length) parts.push(text.slice(last));
return parts;
}
function formatBriefingDate(iso) {
if (!iso) return "";
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const months = ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"];
const days = ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"];
return `${days[d.getDay()]} ${String(d.getDate()).padStart(2, "0")} ${months[d.getMonth()]} · ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
} catch (_) { return ""; }
}
/* ───────── Vista DEMO (mock con regenerar simulado) ───────── */
const BriefingViewDemo = ({ showDisclaimer, onChat }) => {
const [regenerating, setRegenerating] = React.useState(false);
const [selectedReading, setSelectedReading] = React.useState(null);
return (
<>
{
setRegenerating(true);
setTimeout(() => setRegenerating(false), 2400);
}}
onChat={onChat}
/>
{Object.entries(TODAY_READINGS).map(([k, r]) => (
setSelectedReading(k)}
expanded={selectedReading === k}
/>
))}
{showDisclaimer && (
Esto no es consejo médico. El módulo médico interpreta tendencias de tus datos. No reemplaza consulta clínica ni diagnóstico. Ante síntomas, consultá un profesional.
)}
>
);
};
const BriefingHero = ({ regenerating, onRegenerate, onChat }) => (
BRIEFING DEL DÍA · SÁB 02 MAY
Tu cuerpo pide una descarga.
El plan se puede ajustar.
Los 3 agentes coinciden en que estás en una semana de carga alta con HRV
descendente. La recomendación conjunta es bajar el largo del
sábado de 22 a 16 km y subir carbos pre-entreno. Vos decidís.
● coach: ATENCIÓN
● nutri: BIEN
● médico: ATENCIÓN
}>
{regenerating ? "Regenerando…" : "Regenerar lectura"}
}>
Pedir ajustes (chat)
próx. auto-corrida: mañana 06:30
);
const STATUS_TONE = {
good: { color: "var(--good)", label: "BIEN" },
warn: { color: "var(--warn)", label: "ATENCIÓN" },
bad: { color: "var(--bad)", label: "ALERTA" },
};
const ReadingCard = ({ agent, reading, regenerating, onClick, expanded }) => {
const t = STATUS_TONE[reading.status];
return (
{agent.label}
{agent.role}
{t.label}
{reading.headline}
{reading.metric.label}
{reading.metric.value}
target {reading.metric.target}
{reading.findings.map((f, i) => (
))}
conf {(reading.confidence * 100).toFixed(0)}%
${reading.cost.toFixed(3)}
);
};
const TONE_STYLE = {
"BIEN": { color: "var(--good)" },
"ATENCIÓN": { color: "var(--warn)" },
"MEJORA": { color: "var(--accent)" },
"ALERTA": { color: "var(--bad)" },
};
const FindingRow = ({ finding }) => {
const s = TONE_STYLE[finding.tone] || TONE_STYLE.BIEN;
return (
{finding.tone}
{finding.text}
);
};
const PlanDiff = ({ diff, regenerating }) => (
Plan ajustado · {diff.date}
SÍNTESIS CONJUNTA
{diff.changes.length} cambios
{diff.changes.map((c, i) => (
{PANEL_AGENTS[c.agent].label.toLowerCase()} · {c.change}
))}
);
const PlanColumn = ({ label, plan, tone }) => {
const isActive = tone === "active";
return (
{label}
ENTRENO
{plan.workout.title}
{plan.workout.detail}
NUTRICIÓN
{plan.nutri.title}
{plan.nutri.detail}
{plan.notes}
);
};
const DiffStat = ({ label, value }) => (
);
const ActionsBar = ({ onChat }) => (
¿Aplicás los cambios al plan?
Si aceptás, el plan de la semana se reescribe. Podés revertir desde el historial.
}>Rechazar
}>Ajustar
}>Aplicar al plan
);
const HISTORY_STATE = {
applied: { color: "var(--good)", label: "APLICADO" },
pending: { color: "var(--accent)", label: "PENDIENTE" },
skipped: { color: "var(--text-3)", label: "SIN CAMBIOS" },
rejected:{ color: "var(--bad)", label: "RECHAZADO" },
};
/* RegenHistoryLive — wrapper que carga /agents/briefings de verdad y cae al
* mock estático sólo en modo demo. Antes la pestaña Historial siempre
* renderizaba REGEN_HISTORY hardcoded incluso para usuarios reales. */
const RegenHistoryLive = ({ inDemo }) => {
const [items, setItems] = React.useState(inDemo ? REGEN_HISTORY : null);
const [err, setErr] = React.useState(null);
React.useEffect(() => {
if (inDemo) return;
let alive = true;
(async () => {
try {
// briefingsHistory está expuesto en api.js (GET /agents/briefings)
const res = await API.briefingsHistory({ limit: 30 });
const list = Array.isArray(res?.briefings) ? res.briefings : [];
if (!alive) return;
// Mapear al shape esperado por RegenHistory: {date, summary, trigger, state}.
setItems(list.map((b) => ({
date: (b.generated_at || "").slice(0, 16).replace("T", " "),
summary: `Briefing ${String(b.id || "").slice(0, 8)}`,
trigger: "manual",
state: "applied",
})));
} catch (e) {
if (!alive) return;
if (e.status === 401 || e.status === 403) return;
setErr(e.message || "No se pudo cargar el historial.");
setItems([]);
}
})();
return () => { alive = false; };
}, [inDemo]);
if (items === null) {
return (
cargando historial…
);
}
if (err) {
return (
{err}
);
}
if (items.length === 0) {
return (
Sin briefings generados todavía.
Generá uno desde "Lectura de hoy".
);
}
return ;
};
const RegenHistory = ({ items }) => (
Historial de regeneraciones
últimas 7 corridas
{items.map((it, i) => {
const s = HISTORY_STATE[it.state];
return (
{it.date}
{it.summary}
{it.trigger}
{s.label}
Ver
);
})}
);
/* ───────── Chat completo (cableado a /agents/ask) ───────── */
// Sugerencias evergreen — preguntas que funcionan independientemente del estado
// del usuario. Antes había sugerencias específicas (ej. "antes del largo") que
// solo aplicaban a runners y se veían fuera de contexto a otros perfiles.
const SUGGESTIONS = [
"¿Cómo estuvo mi semana?",
"¿Qué dice mi sueño últimamente?",
"Plan para mañana",
];
const ChatFull = ({ showDisclaimer, inDemo }) => {
const [messages, setMessages] = React.useState(inDemo ? MOCK.chat : []);
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
const [error, setError] = React.useState(null);
const [agent, setAgent] = React.useState("coach");
const scrollRef = React.useRef(null);
React.useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages, sending]);
const send = async (text) => {
if (!text.trim() || sending) return;
const userMsg = { role: "user", text, time: nowHM() };
setMessages((prev) => [...prev, userMsg]);
setInput("");
setSending(true);
setError(null);
try {
const res = await API.askAgent({ question: text, agent });
const assistantMsg = {
role: "assistant",
agent: res.agent || agent,
time: nowHM(),
text: res.answer || res.text || res.message || "(sin respuesta)",
sources: res.sources || res.context || [],
cost: res.cost || (res.tokens && res.usd ? { tokens: res.tokens, usd: res.usd } : null),
};
setMessages((prev) => [...prev, assistantMsg]);
} catch (e) {
setError(e.message);
setMessages((prev) => [...prev, {
role: "assistant",
agent,
time: nowHM(),
text: `No pude responder: ${e.message}. Verificá tu LLM en Configuración.`,
error: true,
}]);
} finally {
setSending(false);
}
};
return (
Chat con {PANEL_AGENTS[agent].label}
setAgent(e.target.value)} style={{
padding: "4px 8px", fontSize: 11, fontFamily: "var(--font-mono)",
background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: 4, color: "var(--text)",
}}>
{Object.entries(PANEL_AGENTS).map(([k, a]) => {a.label} )}
{messages.map((m, i) =>
)}
{sending && (
{PANEL_AGENTS[agent].label} · pensando…
●
●
●
)}
{SUGGESTIONS.map((s) => (
send(s)} disabled={sending} style={{
padding: "4px 9px", fontSize: 11, fontFamily: "var(--font-mono)",
background: "var(--bg-2)", border: "1px solid var(--line)",
borderRadius: "var(--r-2)", color: "var(--text-2)", cursor: sending ? "not-allowed" : "pointer",
}}>{s}
))}
setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(input); } }}
placeholder="Pregúntale al coach…"
disabled={sending}
style={{
flex: 1, padding: "10px 14px",
background: "var(--bg-2)", border: "1px solid var(--line)",
borderRadius: "var(--r-2)", fontSize: 13, color: "var(--text)", outline: "none",
}} />
} onClick={() => send(input)} disabled={sending}>
Enviar
COSTE DE LA SESIÓN
Tokens
{messages.filter(m => m.cost).reduce((s, m) => s + (m.cost?.tokens || 0), 0).toLocaleString()}
USD
${messages.filter(m => m.cost).reduce((s, m) => s + (m.cost?.usd || 0), 0).toFixed(4)}
{error && (
ERROR
{error}
)}
);
};
/* ChatContextCard — pequeño panel "qué datos ve el agente" basado en lo que
* realmente está sincronizado. Antes era hardcoded ("87 actividades · plan
* activo · semana 1/4") sin importar el estado real. Ahora consulta:
* - /data/activities → count y rango real
* - /data/daily → count de filas con sleep/HRV
* - /plans/ + /plans/{id} → goal + nº de semanas si hay un plan activo
* Cada línea se omite si no hay datos para evitar mostrar "0" como dato.
*/
const ChatContextCard = ({ inDemo }) => {
const [ctx, setCtx] = React.useState(null);
React.useEffect(() => {
if (inDemo) {
setCtx({
activitiesCount: 12,
dailyCount: 30,
planGoal: "Demo: 5K sub 25",
planWeeks: 8,
});
return;
}
let alive = true;
(async () => {
const out = {};
// Estos fetches son tolerantes — si uno falla, simplemente no mostramos
// esa línea. Importante: nunca mostrar un número inventado al usuario.
try {
const list = await API.activitiesList({ limit: 200 });
const items = Array.isArray(list) ? list : (list?.rows || list?.items || []);
out.activitiesCount = items.length;
} catch (_) {}
try {
const today = new Date();
const from = new Date(today); from.setDate(from.getDate() - 90);
const fmt = (d) => d.toISOString().slice(0, 10);
const daily = await API.dailyData({ from: fmt(from), to: fmt(today) });
const rows = Array.isArray(daily?.rows) ? daily.rows : [];
out.dailyCount = rows.length;
} catch (_) {}
try {
const plansRes = await API.listPlans();
const plans = Array.isArray(plansRes) ? plansRes : (plansRes?.plans || []);
const training = plans.find((p) => (p.type || p.plan_type) === "training");
if (training) {
try {
const detail = await API.getPlan(training.id);
out.planGoal = detail?.content?.goal || training.goal || "plan activo";
out.planWeeks = detail?.content?.weeks || training.weeks || null;
} catch (_) {
out.planGoal = training.goal || "plan activo";
out.planWeeks = training.weeks || null;
}
}
} catch (_) {}
if (alive) setCtx(out);
})();
return () => { alive = false; };
}, [inDemo]);
return (
CONTEXTO ACTUAL
Datos disponibles para el agente:
{ctx === null ? "cargando…" : (
<>
{ctx.dailyCount > 0 && <>· daily {ctx.dailyCount} días >}
{ctx.activitiesCount > 0 && <>· actividades {ctx.activitiesCount} >}
{ctx.planGoal && <>· plan: {ctx.planGoal}{ctx.planWeeks ? ` · ${ctx.planWeeks}sem` : ""} >}
{!ctx.dailyCount && !ctx.activitiesCount && !ctx.planGoal && (
<>· sin datos sincronizados aún>
)}
>
)}
);
};
const ChatBubble = ({ msg, showDisclaimer }) => {
const isUser = msg.role === "user";
const isMedical = msg.agent === "medical";
return (
{isUser ? "Tú" : (msg.agent ? PANEL_AGENTS[msg.agent]?.label || msg.agent : "Coach")}
{msg.time && · {msg.time} }
{isMedical && showDisclaimer && (
No es consejo médico. Tendencias de tus datos. Consultá un profesional ante síntomas.
)}
{/* Mensajes del usuario van como texto plano (no le rompemos su input).
* Los del agente vienen en Markdown — ###, **bold**, listas — y los
* pasamos por MarkdownRender para que se vean como prosa, no como
* el dump de markdown que reportó el usuario. */}
{isUser
? msg.text
:
}
{msg.bullets && (
{msg.bullets.map((b, i) => {b} )}
)}
{msg.followup && (
{msg.followup}
)}
{msg.insights && (
{msg.insights.map((ins, i) => (
))}
)}
{!isUser && (msg.sources?.length || msg.cost) && (
{msg.sources?.length > 0 && }
{msg.cost && (
{msg.cost.tokens?.toLocaleString()} tok · ${(msg.cost.usd ?? 0).toFixed(4)}
)}
)}
);
};
const ChatDrawer = ({ onClose }) => {
const [text, setText] = React.useState("");
return (
e.stopPropagation()} style={{
width: 420, maxWidth: "100%", height: "100%",
background: "var(--bg)", borderLeft: "1px solid var(--line)",
display: "flex", flexDirection: "column",
}}>
Pedile al equipo de coach que ajuste matices de la propuesta de hoy.
{[
{ who: "Tú", text: "¿Y si en vez de 16 km hago 12 km + 6×400m suaves?" },
{ who: "Coach", text: "Funciona — equivale a ~92 unidades de carga. Manteniendo Z2 en los 400. Aprobado por el módulo médico." },
].map((m, i) => (
))}
setText(e.target.value)}
placeholder="Escribí un ajuste…"
style={{
flex: 1, padding: "9px 12px",
background: "var(--bg-2)", border: "1px solid var(--line)",
borderRadius: "var(--r-2)", fontSize: 12.5, color: "var(--text)", outline: "none",
}} />
}>Enviar
);
};
function nowHM() {
const d = new Date();
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
window.Agents = Agents;