/* 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) =>
  1. {renderInline(li)}
  2. )}
); 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 }) => (
{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}
{messages.map((m, i) => )} {sending && (
{PANEL_AGENTS[agent].label} · pensando…
)}
{SUGGESTIONS.map((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", }}>
Pedir ajustes
}>Cerrar
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) => (
{m.who}
{m.text}
))}
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;