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:
co-authored by
Claude Sonnet 5
parent
45f773fb62
commit
5182c45d11
@@ -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>
|
||||
|
||||
@@ -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 →
|
||||
</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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user