feat(studio): tarjetas de agente con señales de estado

La lista de agentes era un directorio: nombre y activo/inactivo. No decía
si el agente estaba realmente listo para atender.

- Tarjetas en grid con el color de marca del agente (el mismo que ve el
  visitante en el widget) y sus iniciales.
- Tres datos por agente: conversaciones de los últimos 7 días, fuentes de
  conocimiento y canales activos. Salen de tres consultas agrupadas, no de
  tres por agente: el N+1 en un listado es el que después no se saca.
- Badge de estado accionable. "sin conocimiento" cuando el agente está
  activo pero no tiene ninguna fuente — ese agente responde de memoria e
  inventa datos, que es exactamente el problema de la URL falsa, y hasta
  ahora no se veía hasta que un cliente recibía la respuesta inventada.
- Esqueleto de carga: la pantalla hace 3 llamadas y quedaba en blanco el
  tiempo suficiente como para parecer rota.

Arregla además el fondo en modo oscuro: index.html traía
<body class="bg-gray-50">, y esa utilidad le ganaba a la regla de body de
la capa base, así que el área principal quedaba clara con el tema oscuro
puesto (medido: --fondo resolvía a 9 13 22 pero el body pintaba 249 250 251).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-14 22:09:32 -05:00
co-authored by Claude Sonnet 5
parent 45f773fb62
commit 5182c45d11
9 changed files with 193 additions and 54 deletions
+87 -22
View File
@@ -18,6 +18,32 @@ const editing = ref(null)
const form = ref(vacio())
const plan = ref(null)
const resumen = ref({})
const cargando = ref(true)
// Un agente sin fuentes de conocimiento responde de memoria e inventa datos
// —es exactamente el problema de la URL falsa— así que eso se avisa acá y no
// recién cuando un cliente recibe una respuesta inventada.
function estadoDe(a) {
const r = resumen.value[a.ID] || {}
if (!a.activo) return { tipo: 'neutro', texto: 'inactivo' }
if (!(r.documentos > 0)) return { tipo: 'alerta', texto: 'sin conocimiento' }
return { tipo: 'ok', texto: 'listo' }
}
function datosDe(a) {
const r = resumen.value[a.ID] || {}
return {
documentos: r.documentos || 0,
canales: r.canales || 0,
conversaciones: r.conversaciones_7d || 0,
}
}
// Iniciales para el avatar; el color de marca del agente lo tiñe.
function iniciales(nombre) {
return String(nombre || '?').trim().split(/\s+/).slice(0, 2).map(p => p[0]).join('').toUpperCase()
}
// max_agentes 0 = ilimitado. Sin plan asignado tampoco hay límite: es
// deliberado (los tenants que existían antes de los planes no se rompen),
@@ -41,6 +67,7 @@ function vacio() {
async function cargar() {
error.value = ''
cargando.value = true
try {
const [t, a, ai] = await Promise.all([
api.get(apiUmind('/umind/tenants')),
@@ -50,9 +77,12 @@ async function cargar() {
tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null
agentes.value = a.items || []
plan.value = a.plan || null
resumen.value = a.resumen || {}
aiConfigs.value = ai.items || []
} catch (e) {
error.value = e.message
} finally {
cargando.value = false
}
}
@@ -132,33 +162,68 @@ onMounted(cargar)
Alcanzaste el máximo de agentes de tu plan.
</p>
<div class="card divide-y divide-borde overflow-hidden">
<UiEmptyState
v-if="agentes.length === 0"
icono="🤖"
titulo="Todavía no hay agentes"
detalle="Creá el primero y cargale su base de conocimiento para que empiece a responder."
/>
<div
<!-- Esqueleto: la carga hace 3 llamadas, y sin esto la pantalla queda en
blanco el tiempo suficiente como para parecer rota. -->
<div v-if="cargando" class="grid gap-3 sm:grid-cols-2">
<div v-for="n in 2" :key="n" class="card p-4 animate-pulse">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-elevado"></div>
<div class="flex-1">
<div class="h-3.5 w-28 rounded bg-elevado"></div>
<div class="h-2.5 w-16 rounded bg-elevado mt-2"></div>
</div>
</div>
<div class="h-2.5 w-full rounded bg-elevado mt-4"></div>
</div>
</div>
<UiEmptyState
v-else-if="agentes.length === 0"
class="card"
icono="🤖"
titulo="Todavía no hay agentes"
detalle="Creá el primero y cargale su base de conocimiento para que empiece a responder."
>
<button class="btn-primary" :disabled="cupo.lleno" @click="nuevoAgente">+ Crear el primer agente</button>
</UiEmptyState>
<div v-else class="grid gap-3 sm:grid-cols-2">
<router-link
v-for="a in agentes"
:key="a.ID"
class="group flex items-center justify-between hover:bg-elevado transition-colors"
:to="`/tenants/${tenantId}/agentes/${a.ID}`"
class="card p-4 relative group hover:shadow-lg hover:-translate-y-0.5 transition-all overflow-hidden"
>
<router-link :to="`/tenants/${tenantId}/agentes/${a.ID}`" class="flex-1 min-w-0 p-4">
<div class="font-medium text-texto">{{ a.nombre }}</div>
<div class="flex items-center gap-1 mt-0.5">
<span class="w-1.5 h-1.5 rounded-full" :style="{ background: a.activo ? a.color || '#22c55e' : undefined }" :class="!a.activo && 'bg-gray-300 dark:bg-gray-600'"></span>
<span class="text-xs text-tenue">{{ a.activo ? 'activo' : 'inactivo' }}</span>
<!-- Franja con el color de marca del agente: el mismo que ve el
visitante en el widget, así la tarjeta se reconoce de un vistazo. -->
<span class="absolute inset-x-0 top-0 h-1" :style="{ background: a.color || '#8eb02f' }"></span>
<div class="flex items-start gap-3">
<span
class="w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-semibold shrink-0"
:style="{ background: a.color || '#8eb02f', opacity: a.activo ? 1 : 0.4 }"
>{{ iniciales(a.nombre) }}</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-texto truncate">{{ a.nombre }}</span>
<span :class="`badge-${estadoDe(a).tipo}`">{{ estadoDe(a).texto }}</span>
</div>
<p class="text-xs text-tenue mt-0.5 truncate">{{ a.tono || 'sin tono definido' }}</p>
</div>
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button class="p-1 text-tenue hover:text-texto" title="Editar" @click.prevent.stop="editarAgente(a)"></button>
<button class="p-1 text-tenue hover:text-red-600" title="Eliminar" @click.prevent.stop="eliminarAgente(a)"></button>
</div>
</router-link>
<div class="flex items-center gap-3 pr-4 text-sm">
<router-link :to="`/tenants/${tenantId}/agentes/${a.ID}`" class="text-brand font-medium opacity-0 group-hover:opacity-100 transition-opacity">
Configurar &rarr;
</router-link>
<button class="text-tenue hover:text-texto opacity-0 group-hover:opacity-100 transition-opacity" title="Editar" @click="editarAgente(a)"></button>
<button class="text-tenue hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity" title="Eliminar" @click="eliminarAgente(a)"></button>
</div>
</div>
<div class="flex items-center gap-4 mt-3.5 pt-3 border-t border-borde text-xs text-tenue">
<span class="tabular-nums"><b class="text-texto font-medium">{{ datosDe(a).conversaciones }}</b> conversaciones · 7d</span>
<span class="tabular-nums"><b class="text-texto font-medium">{{ datosDe(a).documentos }}</b> fuentes</span>
<span class="tabular-nums"><b class="text-texto font-medium">{{ datosDe(a).canales }}</b> canales</span>
</div>
</router-link>
</div>
<div v-if="showForm" class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" @click.self="showForm = false">