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
+4 -1
View File
@@ -5,7 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uMind Studio</title>
</head>
<body class="bg-gray-50">
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
que son los que cambian con el tema. Una utilidad acá le ganaba a la
capa base y dejaba el fondo claro también en modo oscuro. -->
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
+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">
+60
View File
@@ -1,6 +1,8 @@
package models
import (
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
@@ -82,3 +84,61 @@ func GetUmindAgentePorSiteKey(siteKey string) (*UmindAgente, error) {
}
return &a, nil
}
// ResumenAgente son las señales que hacen falta para saber de un vistazo si
// un agente está listo o le falta algo, sin entrar a configurarlo.
type ResumenAgente struct {
AgenteID uint `json:"agente_id"`
Documentos int64 `json:"documentos"`
Canales int64 `json:"canales"`
Conversaciones7d int64 `json:"conversaciones_7d"`
}
// GetResumenAgentes agrega las tres señales en tres consultas agrupadas, no
// en tres por agente: con 5 agentes la diferencia no se nota, pero el patrón
// N+1 en una pantalla de listado es el que después no se puede sacar.
func GetResumenAgentes(agenteIDs []uint) map[uint]*ResumenAgente {
out := make(map[uint]*ResumenAgente, len(agenteIDs))
for _, id := range agenteIDs {
out[id] = &ResumenAgente{AgenteID: id}
}
if len(agenteIDs) == 0 {
return out
}
db := app.Http.Database.DB
type fila struct {
AgenteID uint
Total int64
}
var docs []fila
db.Model(&UmindDocumento{}).Select("agente_id, COUNT(*) AS total").
Where("agente_id IN ? AND deleted_at IS NULL", agenteIDs).Group("agente_id").Scan(&docs)
for _, f := range docs {
if r, ok := out[f.AgenteID]; ok {
r.Documentos = f.Total
}
}
var canales []fila
db.Model(&UmindCanal{}).Select("agente_id, COUNT(*) AS total").
Where("agente_id IN ? AND activo = ? AND deleted_at IS NULL", agenteIDs, true).Group("agente_id").Scan(&canales)
for _, f := range canales {
if r, ok := out[f.AgenteID]; ok {
r.Canales = f.Total
}
}
// Conversaciones, no mensajes: lo que importa es a cuánta gente atendió.
var convs []fila
db.Model(&UmindMensaje{}).Select("agente_id, COUNT(DISTINCT session_id) AS total").
Where("agente_id IN ? AND created_at >= ? AND deleted_at IS NULL", agenteIDs, time.Now().AddDate(0, 0, -7)).
Group("agente_id").Scan(&convs)
for _, f := range convs {
if r, ok := out[f.AgenteID]; ok {
r.Conversaciones7d = f.Total
}
}
return out
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -4,10 +4,13 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uMind Studio</title>
<script type="module" crossorigin src="/orchestrator/assets/index-DILPQ-CQ.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-BJ8q_ag8.css">
<script type="module" crossorigin src="/orchestrator/assets/index-xrbA4kuN.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DyYio0bD.css">
</head>
<body class="bg-gray-50">
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
que son los que cambian con el tema. Una utilidad acá le ganaba a la
capa base y dejaba el fondo claro también en modo oscuro. -->
<body>
<div id="app"></div>
</body>
</html>
+9 -1
View File
@@ -176,7 +176,15 @@ func GetUmindAgentesHandler(c *fiber.Ctx) error {
// en vez de dejar que el límite aparezca recién como un error al guardar.
// Sin plan no hay límite (ver verificarCupoAgentes), y eso también hay que
// poder verlo: si no, un tenant sin plan parece un límite que no funciona.
resp := fiber.Map{"items": items, "usados": len(items)}
ids := make([]uint, len(items))
for i, a := range items {
ids[i] = a.ID
}
resp := fiber.Map{
"items": items,
"usados": len(items),
"resumen": models.GetResumenAgentes(ids),
}
if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil {
resp["plan"] = fiber.Map{"nombre": plan.Nombre, "max_agentes": plan.MaxAgentes}
}