feat: uMind pasa a multi-agente por tenant

Un tenant (negocio/sitio, dueño de los dominios permitidos) puede tener
varios UmindAgente independientes (ej. "Ventas", "Soporte"), cada uno con
su propia config de IA, tono, base de conocimiento, tools, canales y
conexión de correo. El site_key también pasa a ser por agente, así cada
uno tiene su propio <script> de widget embebible y su propio color.

Backend:
- Nuevo modelo UmindAgente (pkg/models/umind_agente.go), con SiteKey,
  AiConfigID, Tono, MensajeBienvenida y Color — campos que antes vivían en
  UmindTenant y se sacan de ahí (las columnas viejas quedan huérfanas sin
  usar, no se hace DROP COLUMN).
- UmindDocumento, UmindChunk, UmindHerramienta, UmindCanal, UmindConexion y
  UmindMensaje pasan de TenantID a AgenteID. El campo se agrega sin
  "not null" para no romper el ALTER TABLE en Postgres sobre tablas que ya
  tienen filas (ej. emetropolitana).
- migrations.MigrarUmindAgentes(): idempotente, crea un agente "Principal"
  por cada tenant existente heredando lo que ya tenía configurado, y mueve
  sus datos de tenant_id a agente_id. Corre en cada arranque normal, mismo
  criterio que los Seed* — nada se rompe para los tenants ya en producción.
- Motor del agente, widget, canales (Telegram/WhatsApp) y OAuth de correo
  ahora operan sobre UmindAgente; el tenant solo se consulta para el
  chequeo de dominio permitido y el nombre del negocio que ve el visitante.

Frontend: nueva jerarquía de navegación tenant → lista de agentes
(TenantAgentes.vue) → detalle de un agente (AgenteDetail.vue, antes
TenantDetail.vue) con las mismas 6 tabs de siempre, ahora por agente. El
modal de tenant en el sidebar se achica a nombre/dominios/activo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 09:30:06 -05:00
co-authored by Claude Sonnet 5
parent 8eba3ab97f
commit f3f2f421d6
27 changed files with 760 additions and 368 deletions
+14 -59
View File
@@ -1,5 +1,5 @@
<script setup>
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api.js'
@@ -7,35 +7,26 @@ const route = useRoute()
const router = useRouter()
const tenants = ref([])
const aiConfigs = ref([])
const loading = ref(true)
const error = ref('')
const showForm = ref(false)
const editing = ref(null)
const form = ref(vacio())
// El tenant "activo" en la nav es tanto /tenants/:id como cualquier ruta
// anidada de sus agentes (/tenants/:tenantId/agentes/:agenteId).
const tenantActivoId = computed(() => route.params.tenantId || route.params.id)
function vacio() {
return {
nombre: '',
dominios_permitidos: '',
ai_config_id: null,
tono: '',
mensaje_bienvenida: '',
color: '#8eb02f',
activo: true,
}
return { nombre: '', dominios_permitidos: '', activo: true }
}
async function cargar() {
loading.value = true
error.value = ''
try {
const [t, ai] = await Promise.all([
api.get('/app/umind/tenants'),
api.get('/app/api/ai-config/select'),
])
const t = await api.get('/app/umind/tenants')
tenants.value = t.items || []
aiConfigs.value = ai.registros || []
} catch (e) {
error.value = e.message
} finally {
@@ -54,10 +45,6 @@ function editarTenant(t) {
form.value = {
nombre: t.nombre,
dominios_permitidos: t.dominios_permitidos,
ai_config_id: t.ai_config_id,
tono: t.tono,
mensaje_bienvenida: t.mensaje_bienvenida,
color: t.color || '#8eb02f',
activo: t.activo,
}
showForm.value = true
@@ -88,9 +75,9 @@ async function guardar() {
}
async function eliminar(t) {
if (!confirm(`¿Eliminar el tenant "${t.nombre}"? Esto no se puede deshacer.`)) return
if (!confirm(`¿Eliminar el tenant "${t.nombre}"? Esto borra también sus agentes. No se puede deshacer.`)) return
await api.del(`/app/umind/tenants/${t.ID}`)
if (route.params.id === String(t.ID)) router.push('/')
if (tenantActivoId.value === String(t.ID)) router.push('/')
await cargar()
}
@@ -124,12 +111,12 @@ onMounted(cargar)
v-for="t in tenants"
:key="t.ID"
class="group flex items-center rounded-lg transition-colors"
:class="route.params.id === String(t.ID) ? 'bg-brand/10 dark:bg-brand/20' : 'hover:bg-gray-100 dark:hover:bg-gray-800'"
:class="tenantActivoId === String(t.ID) ? 'bg-brand/10 dark:bg-brand/20' : 'hover:bg-gray-100 dark:hover:bg-gray-800'"
>
<router-link
:to="`/tenants/${t.ID}`"
class="flex-1 min-w-0 px-2.5 py-2 text-sm"
:class="route.params.id === String(t.ID) ? 'text-brand-dark dark:text-brand font-medium' : 'text-gray-700 dark:text-gray-300'"
:class="tenantActivoId === String(t.ID) ? 'text-brand-dark dark:text-brand font-medium' : 'text-gray-700 dark:text-gray-300'"
>
<div class="truncate">{{ t.nombre }}</div>
<div class="flex items-center gap-1 mt-0.5">
@@ -167,6 +154,9 @@ onMounted(cargar)
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">
{{ editing ? 'Editar tenant' : 'Nuevo tenant' }}
</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Un tenant es el negocio/sitio dueño de los dominios permitidos. La config de IA, tono y demás se configuran por agente, dentro del tenant.
</p>
<form class="space-y-3" @submit.prevent="guardar">
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Nombre</label>
@@ -185,41 +175,6 @@ onMounted(cargar)
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Config de IA</label>
<select
v-model="form.ai_config_id"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
>
<option :value="null"> sin asignar </option>
<option v-for="c in aiConfigs" :key="c.ID" :value="c.ID">
{{ c.nombre }} ({{ c.provider }})
</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Tono / personalidad</label>
<textarea
v-model="form.tono"
rows="2"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
></textarea>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Mensaje de bienvenida</label>
<input
v-model="form.mensaje_bienvenida"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Color del widget</label>
<div class="flex items-center gap-2">
<input v-model="form.color" type="color" class="w-10 h-9 border border-gray-300 dark:border-gray-700 rounded cursor-pointer bg-white dark:bg-gray-800" />
<input v-model="form.color" type="text" pattern="#[0-9a-fA-F]{6}" class="flex-1 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-500 mt-1">Se aplica al widget embebido automáticamente, no hace falta recopiar el código.</p>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<input v-model="form.activo" type="checkbox" />
Activo