feat(umind): Umi, la mascota, y un solo vocabulario en toda la interfaz

La mascota. Umi sale del logo, no es un dibujo aparte: su cuerpo es la "u" del
monograma y el punto del logo pasa a ser su antena, así que símbolo y personaje
son la misma forma vista dos veces. Por eso puede estar al lado del logo sin
competirle.

Tiene estados, y no son decoración: cada pantalla vacía dice algo distinto y la
cara lo dice antes que el texto. Dormida cuando todavía no pasó nada, buscando
cuando falta cargarle información, contenta cuando no hay errores, alerta
cuando algo se rompió. Los ojos toman el color de la superficie de atrás en vez
de ser blancos, así se apoya sobre cualquier tarjeta y en modo oscuro no quedan
dos puntos flotando.

Las pantallas vacías. Había una buena y ocho que decían "Sin canales
configurados." y nada más — el usuario quedaba resolviendo solo qué significa
eso y qué hacer. Ahora las ocho explican qué va ahí y por qué importa: "No está
atendiendo en ningún lado", "Todavía no sabe nada de tu negocio".

El vocabulario. La misma cosa tenía dos nombres según la pantalla: tenant y
espacio, tool y herramienta. Un producto que se llama distinto a sí mismo en
cada lugar se siente como varios productos pegados. Queda: espacio, agente,
herramienta, fuente. Y "al AI" pasa a "a la IA", que además es el género que ya
usaba el resto del panel.

Y la barra de scroll de las pestañas, que se veía gris y gruesa sobre el
contenido: en escritorio las siete entran holgadas y no hacía falta, en móvil
siguen deslizándose pero sin la barra encima.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-19 19:19:01 -05:00
co-authored by Claude Opus 5
parent 5086087e1b
commit 3ceb61c035
13 changed files with 258 additions and 56 deletions
+40 -13
View File
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
const props = defineProps({
tenantId: { type: String, required: true },
@@ -248,7 +249,7 @@ async function guardarTool() {
}
async function eliminarTool(t) {
if (!confirm(`¿Eliminar la tool "${t.nombre}"?`)) return
if (!confirm(`¿Eliminar la herramienta "${t.nombre}"?`)) return
await api.del(apiUmind(`/umind/tools/${t.ID}`))
await cargarTools()
}
@@ -482,7 +483,7 @@ watch(
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<div class="flex gap-1.5 mb-6 overflow-x-auto pb-1">
<div class="flex gap-1.5 mb-6 overflow-x-auto sin-barra pb-1">
<button
v-for="[key, label] in tabs"
:key="key"
@@ -554,7 +555,12 @@ watch(
</form>
</div>
<div class="card divide-y divide-borde">
<div v-if="documentos.length === 0" class="p-6 text-sm text-tenue">Sin fuentes todavía.</div>
<UiEmptyState
v-if="documentos.length === 0"
estado="buscando"
titulo="Todavía no sabe nada de tu negocio"
detalle="Sin información cargada va a contestar que no sabe. Empezá por escribir tus horarios y lo que te preguntan todos los días — es lo más rápido y lo que más se nota."
/>
<div v-for="d in documentos" :key="d.ID" class="p-4 flex items-center justify-between">
<div>
<div class="text-sm text-texto">{{ d.origen }}</div>
@@ -592,13 +598,17 @@ watch(
<!-- Herramientas -->
<div v-else-if="tab === 'herramientas'">
<div class="flex justify-between items-center mb-4">
<p class="label">Máximo 10 tools activas por agente.</p>
<p class="label">Máximo 10 herramientas activas por agente.</p>
<button class="btn-primary" @click="nuevaTool">
+ Nueva tool
+ Nueva herramienta
</button>
</div>
<div class="card divide-y divide-borde">
<div v-if="tools.length === 0" class="p-6 text-sm text-tenue">Sin tools custom todavía.</div>
<UiEmptyState
v-if="tools.length === 0"
titulo="Sin herramientas conectadas"
detalle="Las herramientas le dejan consultar tus sistemas mientras conversa: stock, estado de un pedido, disponibilidad de turnos. Sin ninguna, responde solo con lo que tiene cargado."
/>
<div v-for="t in tools" :key="t.ID" class="p-4 flex items-center justify-between">
<div>
<div class="text-sm text-texto font-mono">{{ t.nombre }}</div>
@@ -618,7 +628,7 @@ watch(
<div v-if="showToolForm" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
<div class="card p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
<h2 class="font-semibold text-texto mb-4">{{ editingTool ? 'Editar tool' : 'Nueva tool' }}</h2>
<h2 class="font-semibold text-texto mb-4">{{ editingTool ? 'Editar herramienta' : 'Nueva herramienta' }}</h2>
<form class="space-y-3" @submit.prevent="guardarTool">
<div>
<label class="label">Nombre (identificador, ej: consultar_stock)</label>
@@ -712,7 +722,11 @@ watch(
</button>
</div>
<div class="card divide-y divide-borde">
<div v-if="canales.length === 0" class="p-6 text-sm text-tenue">Sin canales configurados.</div>
<UiEmptyState
v-if="canales.length === 0"
titulo="No está atendiendo en ningún lado"
detalle="Conectá WhatsApp o Telegram para que empiece a responderle a tus clientes. El widget de tu web funciona aparte, con la clave de sitio de arriba."
/>
<div v-for="c in canales" :key="c.ID" class="p-4">
<div class="flex items-center justify-between">
<div>
@@ -825,7 +839,11 @@ watch(
</button>
</div>
<div class="card divide-y divide-borde">
<div v-if="conexiones.length === 0" class="p-6 text-sm text-tenue">Sin cuentas conectadas.</div>
<UiEmptyState
v-if="conexiones.length === 0"
titulo="Sin cuentas conectadas"
detalle="Conectando una cuenta de correo, el agente puede leer y responder mensajes con tu dirección."
/>
<div v-for="c in conexiones" :key="c.ID" class="p-4 flex items-center justify-between">
<div>
<span class="font-medium text-texto capitalize">{{ c.proveedor }}</span>
@@ -843,7 +861,7 @@ watch(
<div v-else-if="tab === 'chat'" class="card p-4 flex flex-col h-[28rem]">
<div class="flex-1 overflow-y-auto space-y-2 mb-3">
<p v-if="chatMensajes.length === 0" class="text-sm text-tenue">
Probá este agente tal cual lo va a ver un visitante usa la misma config de IA y las mismas tools/base de conocimiento.
Probá este agente tal cual lo va a ver un visitante usa la misma config de IA, las mismas herramientas y la misma base de conocimiento.
</p>
<div
v-for="(m, i) in chatMensajes"
@@ -866,7 +884,11 @@ watch(
<!-- Conversaciones -->
<div v-else-if="tab === 'conversaciones'" class="grid grid-cols-3 gap-4">
<div class="col-span-1 card divide-y divide-borde max-h-[28rem] overflow-y-auto">
<div v-if="sesiones.length === 0" class="p-4 text-sm text-tenue">Sin conversaciones.</div>
<UiEmptyState
v-if="sesiones.length === 0"
titulo="Nadie escribió todavía"
detalle="Acá vas a ver todo lo que le preguntan y qué contestó."
/>
<button
v-for="s in sesiones"
:key="s.session_id"
@@ -894,10 +916,15 @@ watch(
<!-- Auditoría -->
<div v-else>
<p class="label mb-4">
Errores y eventos técnicos de este agente fallos al llamar al AI, a tools, a correo o a los canales. Últimos 100.
Errores y eventos técnicos de este agente fallos al llamar a la IA, a una herramienta, al correo o a los canales. Últimos 100.
</p>
<div class="card divide-y divide-borde max-h-[32rem] overflow-y-auto">
<div v-if="eventos.length === 0" class="p-6 text-sm text-tenue">Sin eventos registrados buena señal.</div>
<UiEmptyState
v-if="eventos.length === 0"
estado="contenta"
titulo="Ningún problema registrado"
detalle="Acá aparecen los errores: una herramienta que no responde, una fuente que no se pudo leer. Que esté vacío es buena señal."
/>
<details v-for="e in eventos" :key="e.ID" class="p-3">
<summary class="cursor-pointer flex items-center gap-2 text-sm">
<span class="px-1.5 py-0.5 rounded text-xs shrink-0" :class="nivelColor(e.nivel)">{{ e.nivel }}</span>