/* Settings — providers, LLM, privacidad, sistema. Cableado a la API real
* con fallback a MOCK cuando el backend no responde. */
// Format an ISO timestamp as a short, relative human string in es-ES.
// "hace 12 min" / "hace 3 h" / "ayer 21:14" / "21 abr 09:30".
const formatRelative = (iso) => {
if (!iso) return "—";
let raw = iso;
if (typeof raw === "string" && /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(raw) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(raw)) {
raw = raw.replace(" ", "T") + "Z";
}
const d = typeof raw === "string" ? new Date(raw) : raw;
if (!(d instanceof Date) || isNaN(d.getTime())) return "—";
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.round(diffMs / 60000);
if (diffMin < 1) return "ahora";
if (diffMin < 60) return `hace ${diffMin} min`;
const diffH = Math.round(diffMin / 60);
if (diffH < 24) return `hace ${diffH} h`;
const sameYear = d.getFullYear() === now.getFullYear();
const time = d.toLocaleTimeString("es-ES", { hour: "2-digit", minute: "2-digit" });
if (diffH < 48) return `ayer ${time}`;
const months = ["ene","feb","mar","abr","may","jun","jul","ago","sep","oct","nov","dic"];
const m = months[d.getMonth()];
const dd = String(d.getDate()).padStart(2, "0");
return sameYear ? `${dd} ${m} ${time}` : `${dd} ${m} ${d.getFullYear()}`;
};
const SETTINGS_AGENTS = {
coach: { label: "Coach", icon: "coach", color: "var(--accent)" },
nutri: { label: "Nutricionista", icon: "apple", color: "var(--good)" },
medical: { label: "Médico", icon: "medical", color: "var(--info)" },
};
const Settings = ({ density, inDemo, onLogout, setRoute }) => {
const [tab, setTab] = React.useState("providers");
return (
{[
{ v: "providers", l: "Proveedores" },
{ v: "llm", l: "LLM" },
{ v: "usage", l: "Uso y costes" },
{ v: "privacy", l: "Privacidad" },
{ v: "system", l: "Sistema" },
].map(t => (
setTab(t.v)} style={{
padding: "8px 12px", textAlign: "left",
background: tab === t.v ? "var(--bg-2)" : "transparent",
border: "none", borderRadius: "var(--r-2)",
color: tab === t.v ? "var(--text)" : "var(--text-2)",
fontSize: 13, fontWeight: tab === t.v ? 500 : 400,
}}>{t.l}
))}
{tab === "providers" && }
{tab === "llm" && }
{tab === "usage" && }
{tab === "privacy" && }
{tab === "system" && }
);
};
const SettingsProviders = ({ setRoute }) => {
const [providers, setProviders] = React.useState(null);
const [error, setError] = React.useState(null);
const [syncing, setSyncing] = React.useState(null);
const [connectFor, setConnectFor] = React.useState(null); // provider name for inline modal
const load = React.useCallback(async () => {
if (API.demoMode()) {
setProviders(MOCK.providers);
return;
}
try {
const data = await API.listProviders();
// /providers/ trae sólo el registry (nombre/modos/caps); el estado
// de conexión vive en /providers/{name}/status. Merge en paralelo.
const list = Array.isArray(data) ? data : (data?.providers || []);
const statuses = await Promise.all(
list.map(p => {
const name = typeof p === "string" ? p : (p.name || p.id);
return name
? API.providerStatus(name).catch(() => null)
: Promise.resolve(null);
})
);
const normalized = list.map((p, i) => {
const base = typeof p === "string"
? { name: p, mode: "POLL", caps: [] }
: {
name: p.name || p.id || "?",
mode: p.mode || (p.modes?.[0]) || "POLL",
caps: p.capabilities || p.caps || [],
};
const st = statuses[i] || {};
const auth = st.auth || st.status; // /status usa {auth}, /auth/status usa {status}
const lastSyncIso = st.last_sync || st.lastSync || null;
return {
...base,
logo: (base.name || "?")[0]?.toUpperCase(),
state: auth === "logged_in" ? "ok" : "off",
lastSync: lastSyncIso ? formatRelative(lastSyncIso) : (auth === "logged_in" ? "—" : "—"),
syncing: !!st.syncing,
};
});
setProviders(normalized);
setError(null);
} catch (e) {
setError(e.message);
// Si la lista de providers ya estaba cargada antes (re-fetch tras
// un sync), la dejamos como estaba en lugar de pisarla con MOCK.
// Si nunca cargó, mostramos lista vacía + mensaje de error abajo.
if (providers === null) setProviders([]);
}
}, [providers]);
React.useEffect(() => { load(); }, [load]);
const triggerSync = async (name) => {
setSyncing(name);
try {
await API.sync(name);
await load();
} catch (e) {
alert(`Sync falló: ${e.message}`);
} finally {
setSyncing(null);
}
};
// En demo seguimos mostrando MOCK.providers para preview; en producción
// usamos lo que vino del backend. Mientras carga, lista vacía.
const list = providers || (API.demoMode() ? MOCK.providers : []);
const connected = list.filter((p) => p.state === "ok");
const available = list.filter((p) => p.state !== "ok");
const [showAddModal, setShowAddModal] = React.useState(false);
return (
<>
{error && (
Backend no responde: {error}
)}
PROVEEDORES CONECTADOS
{connected.length > 0 && available.length > 0 && (
}
onClick={() => setShowAddModal(true)}
>
Agregar proveedor
)}
{connected.length === 0 ? (
// Empty state — el primer onboarding visible cuando no hay nada.
No tenés proveedores conectados aún.
Conectá Garmin, Eufy u Oura para que el coach trabaje con tus datos reales.
Sin proveedores, los planes se generan a ciegas.
{available.length > 0 ? (
} onClick={() => setShowAddModal(true)}>
Agregar tu primer proveedor
) : (
no hay proveedores disponibles en el registry
)}
) : (
{connected.map((p, i) => (
triggerSync(p.name)}
last={i === connected.length - 1}
/>
))}
)}
{showAddModal && (
setShowAddModal(false)}
onPick={(name) => { setShowAddModal(false); setConnectFor(name); }}
/>
)}
{connectFor && (
setConnectFor(null)}
onConnected={() => { setConnectFor(null); load(); }}
setRoute={setRoute}
/>
)}
>
);
};
// Inline connect flow used by SettingsProviders.
// Garmin: email + password + (MFA code si Garmin lo pide).
// Eufy / Oura: por ahora derivamos al asistente de configuración (que tiene
// el flujo BLE / PAT completo); el botón es un atajo coherente.
/* ConnectedProviderRow — fila por proveedor activo con sync + menú "⋯".
* Antes había Sync y Desconectar siempre visibles (ruido). Ahora el menú
* mantiene el row limpio y sólo expone acciones secundarias bajo demanda. */
const ConnectedProviderRow = ({ provider, syncing, onSync, last }) => {
const p = provider;
const [menuOpen, setMenuOpen] = React.useState(false);
React.useEffect(() => {
if (!menuOpen) return undefined;
const close = () => setMenuOpen(false);
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, [menuOpen]);
return (
{p.logo}
{p.name}
{p.mode}
{p.caps.slice(0, 4).map((c) => (
{c}
))}
{p.caps.length > 4 && (
+{p.caps.length - 4}
)}
Activo
sincronizado {p.lastSync}
e.stopPropagation()}>
}
onClick={onSync}
disabled={syncing}
>
{syncing ? "Sync…" : "Sincronizar"}
setMenuOpen((v) => !v)}
title="Más opciones"
>
⋯
{menuOpen && (
{
setMenuOpen(false);
if (confirm(`¿Desconectar ${p.name}? Vas a poder volver a conectarlo desde "+ Agregar".`)) {
// No hay endpoint /providers/{name}/disconnect aún — flag descriptivo.
alert("La desconexión todavía no está cableada al backend. Borrá el secret manualmente o reseteá el contenedor.");
}
}}
style={{
display: "block", width: "100%", textAlign: "left",
padding: "8px 12px", background: "transparent", border: "none",
color: "var(--bad)", fontSize: 12.5, cursor: "pointer", borderRadius: 4,
}}
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bad-soft)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
Desconectar
)}
);
};
/* AddProviderModal — grilla de proveedores aún no conectados. Reusa los
* estilos modalBackdrop/modalBody existentes. Click sobre una tarjeta dispara
* el flujo `setConnectFor(name)` del parent que abre el wizard inline
* apropiado (Garmin: email+MFA; Eufy: BLE pair; Oura: PAT). Todo dentro del
* mismo modal — no se sale a otra pantalla. */
const AddProviderModal = ({ available, onClose, onPick }) => (
e.stopPropagation()} style={{ ...modalBody, width: "min(640px, 100%)" }}>
AGREGAR PROVEEDOR
¿Qué fuente de datos querés sumar?
Cerrar
{available.length === 0 ? (
No quedan proveedores por sumar — ya tenés conectados todos los disponibles.
) : (
{available.map((p) => (
onPick(p.name)}
style={{
display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 10,
padding: 16,
background: "var(--bg-1)",
border: "1px solid var(--line)",
borderRadius: "var(--r-2)",
textAlign: "left",
cursor: "pointer",
transition: "border-color 0.12s",
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "var(--accent)")}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "var(--line)")}
>
{p.caps && p.caps.length > 0 && (
{p.caps.slice(0, 5).map((c) => {c} )}
{p.caps.length > 5 && +{p.caps.length - 5} }
)}
Conectar →
))}
)}
);
/* EufyConnectModal — wraps the existing EufyPair (loaded by screen-onboarding)
* inside a sized modal. Antes derivábamos al asistente full-screen; ahora todo
* el wizard de scan/configure/listener-restart vive dentro del mismo modal. */
const EufyConnectModal = ({ onClose, onConnected }) => {
// El sub-componente maneja sus propios pasos (idle / scanning / results)
// pero pide que el "outer state" se exponga. Acá lo hosteamos.
const [pairState, setPairState] = React.useState("idle");
const EufyPairComp = (typeof window !== "undefined") ? window.EufyPair : null;
return (
e.stopPropagation()} style={{ ...modalBody, width: "min(640px, 100%)", maxHeight: "88vh", overflowY: "auto" }}>
{EufyPairComp ? (
) : (
Cargando wizard de Eufy…
)}
);
};
/* OuraConnectModal — pequeño formulario inline para el PAT (personal access
* token) de Oura Cloud. El backend valida el token contra /v2/usercollection
* /personal_info y lo persiste cifrado en common.secrets. */
const OuraConnectModal = ({ onClose, onConnected }) => {
const [pat, setPat] = React.useState("");
const [stage, setStage] = React.useState("idle"); // idle | submitting | done | error
const [errMsg, setErrMsg] = React.useState(null);
const submit = async () => {
if (!pat || pat.trim().length < 16) {
setErrMsg("Pegá tu Personal Access Token (lo generás en cloud.ouraring.com → Personal Access Tokens).");
return;
}
setStage("submitting");
setErrMsg(null);
try {
const res = await API.startAuth("oura", {
style: "api_key",
credentials: { personal_access_token: pat.trim() },
});
if (res?.status === "logged_in" || res?.auth === "logged_in") {
setStage("done");
setTimeout(() => onConnected && onConnected(), 600);
} else {
setStage("error");
setErrMsg(res?.error || "No se pudo validar el PAT con Oura.");
}
} catch (e) {
setStage("error");
setErrMsg(e.detail?.detail || e.message || "Error verificando el PAT.");
}
};
const fs = (typeof window !== "undefined" && window.fieldStyle3) || {
width: "100%", padding: "10px 12px", background: "var(--bg-2)",
border: "1px solid var(--line)", borderRadius: "var(--r-2)",
fontSize: 13, color: "var(--text)", outline: "none", marginTop: 4,
};
return (
e.stopPropagation()} style={modalBody}>
Pegá tu Personal Access Token
{stage === "done" ? (
✓ Oura conectado. Cerrando…
) : (
<>
setPat(e.target.value)}
placeholder="ULNZQ7..."
autoComplete="off"
spellCheck={false}
disabled={stage === "submitting"}
style={{ ...fs, fontFamily: "var(--font-mono)" }}
/>
{errMsg && (
Error: {errMsg}
)}
Cancelar
{stage === "submitting" ? "Validando…" : "Conectar"}
>
)}
);
};
const ConnectModal = ({ provider, onClose, onConnected, setRoute }) => {
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [mfa, setMfa] = React.useState("");
const [stage, setStage] = React.useState("idle"); // idle | submitting | awaiting_mfa | done | error
const [errMsg, setErrMsg] = React.useState(null);
const [waitInfo, setWaitInfo] = React.useState(null); // {elapsedSec} mientras pollea
const close = () => onClose && onClose();
// Sondear /auth/status hasta llegar a un estado terminal. Garmin con
// Cloudflare puede tardar 3-5 min (mobile→widget→portal con backoffs
// de 13-19s entre intentos). Por eso 5 min de timeout en lugar de 2.
const pollUntilTerminal = async (timeoutMs = 300000, onTick) => {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
await new Promise(r => setTimeout(r, 1500));
try {
const s = await API.providerStatus("garmin");
const auth = s?.auth || s?.status;
const elapsedSec = Math.round((Date.now() - t0) / 1000);
if (onTick) onTick({ elapsedSec, auth });
if (auth === "logged_in") return { ok: true, status: auth };
if (auth === "awaiting_mfa") return { ok: false, mfa: true, status: auth };
if (auth === "error") return { ok: false, status: auth, error: s.auth_error || s.error };
} catch (_) { /* sigue sondeando */ }
}
return {
ok: false,
status: "timeout",
error: "Garmin tardó más de 5 min — probablemente la IP está rate-limiteada por Cloudflare. El backend sigue intentando en background; podés cerrar y revisar el estado en unos minutos.",
};
};
const startGarmin = async () => {
if (!email || !password) { setErrMsg("Email y contraseña requeridos."); return; }
setStage("submitting");
setErrMsg(null);
try {
await API.startAuth("garmin", {
style: "interactive",
credentials: { email, password },
});
// El POST /auth/start retorna casi inmediato; el auth real corre en
// background. Polleamos /auth/status hasta estado terminal.
setWaitInfo({ elapsedSec: 0 });
const final = await pollUntilTerminal(300000, (tick) => setWaitInfo(tick));
setWaitInfo(null);
if (final.ok) {
setStage("done");
setTimeout(() => { onConnected && onConnected(); }, 600);
} else if (final.mfa) {
setStage("awaiting_mfa");
} else {
setStage("error");
setErrMsg(final.error || "No se pudo iniciar sesión.");
}
} catch (e) {
setStage("error");
setErrMsg(e.detail?.detail || e.message || "No se pudo iniciar sesión con Garmin.");
}
};
const submitMfa = async () => {
if (!mfa) { setErrMsg("Pegá el código MFA que te llegó al email."); return; }
setStage("submitting");
setErrMsg(null);
try {
await API.submitMfa("garmin", { code: mfa });
// submit_mfa también termina el auth en background; sondear status.
const final = await pollUntilTerminal(60000);
if (final.ok) {
setStage("done");
setTimeout(() => { onConnected && onConnected(); }, 600);
} else if (final.mfa) {
setStage("awaiting_mfa");
setErrMsg("El backend sigue esperando MFA. Reintentá con el código.");
} else {
setStage("error");
setErrMsg(final.error || "Código MFA rechazado.");
}
} catch (e) {
setStage("error");
setErrMsg(e.detail?.detail || e.message || "Error verificando MFA.");
}
};
// Eufy: wizard inline reusando window.EufyPair (definido en screen-onboarding).
// Antes redirigíamos a /onboarding y el flujo se sentía partido.
if (provider === "eufy") {
return { onConnected && onConnected(); }} />;
}
if (provider === "oura") {
return { onConnected && onConnected(); }} />;
}
return (
e.stopPropagation()} style={modalBody}>
CONECTAR · GARMIN
Iniciá sesión con Garmin Connect.
Tus credenciales nunca salen del contenedor. Garmin puede pedir un código de verificación de dispositivo al primer login.
{stage !== "awaiting_mfa" && stage !== "done" && (
<>
setEmail(e.target.value)}
placeholder="email@dominio.com" style={modalField}
disabled={stage === "submitting"}
/>
setPassword(e.target.value)}
placeholder="contraseña" style={modalField}
disabled={stage === "submitting"}
onKeyDown={(e) => { if (e.key === "Enter") startGarmin(); }}
/>
{stage === "submitting" && waitInfo && (
Conectando contra Garmin · {waitInfo.elapsedSec}s
Cloudflare a veces nos hace esperar 2-5 min entre intentos. Podés cerrar la modal — el backend sigue intentando y el provider se va a marcar como Activo cuando termine.
)}
{errMsg &&
{errMsg}
}
{stage === "submitting" ? "Cerrar (sigue en background)" : "Cancelar"}
{stage === "submitting" ? "Conectando…" : "Iniciar sesión"}
>
)}
{stage === "awaiting_mfa" && (
<>
Garmin te envió un código de verificación. Revisá tu email (incluyendo spam).
setMfa(e.target.value)}
placeholder="código (6 dígitos)" style={{ ...modalField, marginBottom: 12, fontFamily: "var(--font-mono)" }}
autoFocus
onKeyDown={(e) => { if (e.key === "Enter") submitMfa(); }}
/>
{errMsg &&
{errMsg}
}
{ setStage("idle"); setMfa(""); setErrMsg(null); }}>← volver
Reenviar
Verificar
>
)}
{stage === "done" && (
Garmin conectado.
)}
);
};
const modalBackdrop = {
position: "fixed", inset: 0, zIndex: 1000,
background: "color-mix(in srgb, #000 65%, transparent)",
display: "grid", placeItems: "center",
padding: 24,
};
const modalBody = {
width: "min(440px, 100%)",
background: "var(--bg-1)",
border: "1px solid var(--line)",
borderRadius: "var(--r-3, 12px)",
padding: "20px 22px",
boxShadow: "0 24px 80px -20px rgba(0,0,0,0.6)",
};
const modalField = {
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-2)",
color: "var(--text)",
padding: "10px 12px",
fontSize: 13.5,
width: "100%",
outline: "none",
};
const SettingsLLM = () => {
// provider en lowercase para que matchee el registro del backend
// (LLM_PROVIDERS["openai"]). Los selects muestran labels capitalizados.
const [config, setConfig] = React.useState({
provider: "openai",
model: "gpt-4o-mini",
api_key: "",
base_url: "",
configured: false, // true tras un GET /config/llm que devuelve { configured: true }
});
const [missing, setMissing] = React.useState(false);
const [testStatus, setTestStatus] = React.useState({ kind: "idle" });
React.useEffect(() => {
if (API.demoMode()) return;
API.getLLMConfig().then((c) => {
// Backend devuelve { configured, provider, model_default, models_per_agent, base_url }.
const isConfigured = c?.configured === true;
setConfig((prev) => ({
...prev,
provider: (c?.provider || prev.provider).toLowerCase(),
model: c?.model_default || c?.default_model || c?.model || prev.model,
base_url: c?.base_url || "",
api_key: "", // nunca se pre-rellena; el backend nunca devuelve la key en claro
configured: isConfigured,
}));
setMissing(!isConfigured);
}).catch((e) => {
// 401/403 los maneja el evento global; 404/otros marcan "no configurado".
setMissing(true);
});
}, []);
const testConnection = async () => {
if (!config.api_key || config.api_key.includes("•")) {
setTestStatus({ kind: "bad", msg: "Pegá una API key real para probar." });
return;
}
setTestStatus({ kind: "testing" });
const t0 = performance.now();
try {
await API.setLLMConfig({
provider: config.provider.toLowerCase(),
model_default: config.model,
api_key: config.api_key,
base_url: config.base_url || null,
});
setTestStatus({ kind: "ok", ms: Math.round(performance.now() - t0) });
setConfig((prev) => ({ ...prev, configured: true, api_key: "" }));
setMissing(false);
} catch (e) {
setTestStatus({ kind: "bad", msg: e.message });
}
};
return (
<>
{missing && (
LLM sin configurar. Sin esto el chat con agentes y la generación de planes están deshabilitados. Completá los campos abajo para activar al coach.
)}
PROVEEDOR LLM
setConfig({ ...config, provider: e.target.value })} style={settingsField}>
OpenAI
{/* otros se enchufan cuando se registren backend-side
(anthropic, ollama, gemini, etc.) */}
setConfig({ ...config, model: e.target.value })} style={settingsField}>
gpt-4o-mini
gpt-4o
gpt-4-turbo
o1
o1-mini
setConfig({ ...config, api_key: e.target.value })}
style={settingsField}
/>
setConfig({ ...config, base_url: e.target.value })}
style={settingsField}
/>
} onClick={testConnection} disabled={testStatus.kind === "testing"}>
{testStatus.kind === "testing" ? "Probando…" : "Probar conexión"}
{testStatus.kind === "ok" && (
ok · {testStatus.ms}ms
)}
{testStatus.kind === "bad" && (
{testStatus.msg}
)}
MODELO POR AGENTE
{Object.entries(SETTINGS_AGENTS).map(([k, a], i) => (
{a.label}
gpt-4o-mini (defecto)
gpt-4o
claude-haiku-4-5
))}
>
);
};
const SettingsUsage = () => {
const [usage, setUsage] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [err, setErr] = React.useState(null);
React.useEffect(() => {
if (API.demoMode()) {
setUsage({
by_agent: [
{ agent: "coach", prompt: 12000, completion: 4500, cost: "0.18" },
{ agent: "briefing.coach", prompt: 8000, completion: 1200, cost: "0.10" },
],
by_model: [{ model: "gpt-4o-mini", prompt: 20000, completion: 5700, cost: "0.28" }],
total_tokens: 25700,
total_cost_usd: "0.28",
});
setLoading(false);
return;
}
API.getLLMUsage()
.then((u) => { setUsage(u); setLoading(false); })
.catch((e) => { setErr(e.message || "No se pudo cargar uso"); setLoading(false); });
}, []);
const totalTokens = Number(usage?.total_tokens || 0);
const totalCost = Number(usage?.total_cost_usd || 0);
const byAgent = Array.isArray(usage?.by_agent) ? usage.by_agent : [];
const byModel = Array.isArray(usage?.by_model) ? usage.by_model : [];
return (
<>
USO LLM · ACUMULADO
{loading ? (
cargando uso…
) : err ? (
{err}
) : totalTokens === 0 ? (
Sin llamadas LLM registradas todavía.
Generá un briefing o un plan para empezar a ver consumo aquí.
) : (
<>
{byAgent.length > 0 && (
POR AGENTE
{["AGENTE", "PROMPT", "COMPLETION", "USD"].map((h) => (
{h}
))}
{byAgent.map((row, i) => (
{row.agent}
{fmtTokens(Number(row.prompt))}
{fmtTokens(Number(row.completion))}
${Number(row.cost || 0).toFixed(4)}
))}
)}
{byModel.length > 0 && (
POR MODELO
{["MODELO", "PROMPT", "COMPLETION", "USD"].map((h) => (
{h}
))}
{byModel.map((row, i) => (
{row.model}
{fmtTokens(Number(row.prompt))}
{fmtTokens(Number(row.completion))}
${Number(row.cost || 0).toFixed(4)}
))}
)}
>
)}
>
);
};
const SettingsPrivacy = () => (
<>
PRIVACIDAD
OpenAI procesará tus datos en sus servidores. Si querés procesamiento 100% local, configurá Ollama cuando esté disponible. Tus datos crudos nunca salen del contenedor; solo lo que mandás explícitamente al chat.
>
);
const SettingsSystem = ({ onLogout, setRoute }) => {
const [info, setInfo] = React.useState(null);
React.useEffect(() => {
if (API.demoMode()) {
setInfo({ version: "0.4.2", status: "demo" });
return;
}
Promise.all([API.health().catch(() => null), API.version().catch(() => null)]).then(([h, v]) => {
setInfo({
version: v?.version || h?.version || "0.4.2",
status: h?.status || "running",
});
});
}, []);
const v = info?.version || "0.4.2";
return (
<>
SISTEMA
{API.getUser()?.email || (API.demoMode() ? "demo" : "—")}
{ (onLogout ? onLogout() : (async () => { await API.authLogout(); window.location.reload(); })()); }}>Cerrar sesión
>} />
{API.getBase() || "(mismo origen)"}
>} />
ver últimos 1000 →} last />
CONFIGURACIÓN INICIAL
Asistente de configuración
Recorré de nuevo el flujo guiado para conectar proveedores y configurar el LLM.
Útil cuando agregás un dispositivo nuevo o cambiás de modelo.
setRoute && setRoute("onboarding")}
>
Abrir asistente →
EXPORTAR
}>Descargar dump DuckDB
}>Exportar CSV de actividades
}>Exportar histórico LLM
>
);
};
const PrivacyRow = ({ title, body, on, last, disabled }) => (
{title}
{disabled && próximamente }
{body}
);
const Toggle = ({ on, disabled }) => (
);
const SysRow = ({ l, v, last }) => (
);
const UsageStat = ({ label, value, unit }) => (
{label}
{value}
{unit && {unit} }
);
const settingsField = {
width: "100%", padding: "8px 10px", background: "var(--bg-2)", border: "1px solid var(--line)",
borderRadius: "var(--r-2)", fontSize: 12.5, color: "var(--text)", outline: "none", marginTop: 4,
};
const SettingsField = ({ label, children, full }) => (
);
function fmtTokens(n) {
if (n == null) return "—";
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
function maskToken(t) {
if (!t) return "—";
if (t.length <= 8) return "•".repeat(t.length);
return t.slice(0, 4) + "•".repeat(Math.min(12, t.length - 8)) + t.slice(-4);
}
window.Settings = Settings;