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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user