feat: orquestador uMind (SPA Vue) + tools custom + canales Telegram/WhatsApp

SPA nueva en /orchestrator (Vue 3 + Vite, servida por el mismo binario Go
bajo /orchestrator para que la cookie de sesión funcione sin tocar CORS),
reemplaza al panel Alpine.js como punto de entrada del menú.

Backend, todo aditivo sobre el motor de uMind ya existente:
- UmindHerramienta: tools custom por tenant que llaman un webhook HTTP,
  integradas al loop de function-calling existente. Cliente HTTP con
  guardas SSRF (bloqueo de IPs privadas/loopback/link-local resuelto en el
  momento de conectar, no antes, para cerrar la ventana de DNS rebinding)
  que no existían en el proyecto.
- UmindCanal: Telegram y WhatsApp Business Cloud API como canales
  adicionales del mismo agente que ya atiende el widget web, ambos
  reusando ProcessWidgetMessage. WhatsApp valida X-Hub-Signature-256.
  Credenciales cifradas en reposo con el mismo AES-GCM+APP_KEY que ya usa
  el proyecto para la contraseña SMTP (primer uso para secretos de uMind).
- Se conecta middlewares.Limit() (rate limiter que existía pero no se
  usaba en ningún lado) al widget público y a los webhooks nuevos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-11 22:16:20 -05:00
co-authored by Claude Sonnet 5
parent aaf36b33ce
commit da0bffe661
30 changed files with 2105 additions and 26 deletions
+17
View File
@@ -0,0 +1,17 @@
<template>
<div class="min-h-screen">
<header class="bg-white border-b border-gray-200">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<router-link to="/" class="text-lg font-semibold text-gray-800">
uMind <span class="text-brand">Orquestador</span>
</router-link>
<a href="/app/dashboard" class="text-sm text-gray-500 hover:text-gray-700">
&larr; Volver al panel
</a>
</div>
</header>
<main class="max-w-6xl mx-auto px-6 py-8">
<router-view />
</main>
</div>
</template>
+29
View File
@@ -0,0 +1,29 @@
// Wrapper de fetch para las rutas de sesión /app/umind/*. La cookie de
// sesión viaja sola por ser mismo origen. Si el JWT expiró, AuthWeb()
// redirige a /login devolviendo HTML en vez de un 401 JSON — fetch sigue
// ese redirect solo, así que lo detectamos por el content-type de vuelta.
async function request(path, options = {}) {
const res = await fetch(path, {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
})
const contentType = res.headers.get('content-type') || ''
if (res.redirected || !contentType.includes('application/json')) {
window.location.href = '/login'
throw new Error('Sesión expirada')
}
const data = await res.json()
if (!res.ok) {
throw new Error(data?.error || data?.message || 'Error de servidor')
}
return data
}
export const api = {
get: (path) => request(path),
post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) }),
put: (path, body) => request(path, { method: 'PUT', body: JSON.stringify(body) }),
del: (path) => request(path, { method: 'DELETE' }),
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
import './style.css'
createApp(App).use(router).mount('#app')
+13
View File
@@ -0,0 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import TenantsList from './views/TenantsList.vue'
import TenantDetail from './views/TenantDetail.vue'
const router = createRouter({
history: createWebHistory('/orchestrator/'),
routes: [
{ path: '/', name: 'tenants', component: TenantsList },
{ path: '/tenants/:id', name: 'tenant-detail', component: TenantDetail, props: true },
],
})
export default router
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+548
View File
@@ -0,0 +1,548 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { api } from '../lib/api.js'
const props = defineProps({ id: { type: String, required: true } })
const tenantId = computed(() => Number(props.id))
const tenant = ref(null)
const error = ref('')
const tab = ref('conocimiento')
// ─── Base de conocimiento ───────────────────────────────────────────────────
const documentos = ref([])
const nuevaUrl = ref('')
const maxPaginas = ref(30)
const ingestando = ref(false)
async function cargarTenant() {
const t = await api.get('/app/umind/tenants')
tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null
}
async function cargarDocumentos() {
const r = await api.get(`/app/umind/documentos?tenant_id=${props.id}`)
documentos.value = r.items || []
}
async function agregarFuente() {
if (!nuevaUrl.value.trim()) return
ingestando.value = true
error.value = ''
try {
await api.post('/app/umind/documentos', {
tenant_id: tenantId.value,
url: nuevaUrl.value.trim(),
max_paginas: Number(maxPaginas.value) || 30,
})
nuevaUrl.value = ''
await cargarDocumentos()
} catch (e) {
error.value = e.message
} finally {
ingestando.value = false
}
}
async function eliminarDocumento(id) {
if (!confirm('¿Eliminar esta fuente y sus fragmentos indexados?')) return
await api.del(`/app/umind/documentos/${id}`)
await cargarDocumentos()
}
const estadoColor = computed(() => (estado) => ({
listo: 'bg-green-100 text-green-700',
procesando: 'bg-amber-100 text-amber-700',
pendiente: 'bg-gray-100 text-gray-500',
error: 'bg-red-100 text-red-700',
}[estado] || 'bg-gray-100 text-gray-500'))
// ─── Conversaciones ──────────────────────────────────────────────────────────
const sesiones = ref([])
const historial = ref([])
const sesionActiva = ref(null)
async function cargarSesiones() {
const r = await api.get(`/app/umind/sesiones?tenant_id=${props.id}`)
sesiones.value = r.items || []
}
async function verHistorial(sessionId) {
sesionActiva.value = sessionId
const r = await api.get(`/app/umind/historial?tenant_id=${props.id}&session_id=${sessionId}`)
historial.value = r.items || []
}
// ─── Tools custom (webhooks) ─────────────────────────────────────────────────
const tools = ref([])
const showToolForm = ref(false)
const editingTool = ref(null)
const toolForm = ref(toolVacio())
function toolVacio() {
return {
nombre: '',
descripcion: '',
url: '',
auth_header_nombre: '',
auth_header_valor: '',
tocarAuth: false,
parametros: [],
activa: true,
}
}
async function cargarTools() {
const r = await api.get(`/app/umind/tools?tenant_id=${props.id}`)
tools.value = r.items || []
}
function nuevaTool() {
editingTool.value = null
toolForm.value = toolVacio()
showToolForm.value = true
}
function editarTool(t) {
editingTool.value = t
let parametros = []
try {
parametros = JSON.parse(t.parametros_json || '[]') || []
} catch {
parametros = []
}
toolForm.value = {
nombre: t.nombre,
descripcion: t.descripcion,
url: t.url,
auth_header_nombre: t.auth_header_nombre,
auth_header_valor: '',
tocarAuth: false,
parametros,
activa: t.activa,
}
showToolForm.value = true
}
function agregarParametro() {
toolForm.value.parametros.push({ nombre: '', tipo: 'string', descripcion: '', requerido: false })
}
function quitarParametro(i) {
toolForm.value.parametros.splice(i, 1)
}
async function guardarTool() {
const payload = {
tenant_id: tenantId.value,
nombre: toolForm.value.nombre.trim(),
descripcion: toolForm.value.descripcion,
url: toolForm.value.url.trim(),
auth_header_nombre: toolForm.value.auth_header_nombre,
parametros: toolForm.value.parametros,
activa: toolForm.value.activa,
}
// auth_header_valor solo va si el usuario efectivamente lo tocó — así una
// edición sin cambiar el secreto no lo borra ni lo re-envía en claro.
if (toolForm.value.tocarAuth) {
payload.auth_header_valor = toolForm.value.auth_header_valor
}
try {
if (editingTool.value) {
await api.put(`/app/umind/tools/${editingTool.value.ID}`, payload)
} else {
await api.post('/app/umind/tools', payload)
}
showToolForm.value = false
await cargarTools()
} catch (e) {
error.value = e.message
}
}
async function eliminarTool(t) {
if (!confirm(`¿Eliminar la tool "${t.nombre}"?`)) return
await api.del(`/app/umind/tools/${t.ID}`)
await cargarTools()
}
// ─── Canales ──────────────────────────────────────────────────────────────────
const canales = ref([])
const showCanalForm = ref(false)
const canalForm = ref(canalVacio())
function canalVacio() {
return {
tipo: 'telegram',
bot_token: '',
phone_number_id: '',
access_token: '',
app_secret: '',
verify_token: '',
}
}
async function cargarCanales() {
const r = await api.get(`/app/umind/canales?tenant_id=${props.id}`)
canales.value = r.items || []
}
function nuevoCanal() {
canalForm.value = canalVacio()
showCanalForm.value = true
}
async function guardarCanal() {
const credenciales =
canalForm.value.tipo === 'telegram'
? { bot_token: canalForm.value.bot_token }
: {
phone_number_id: canalForm.value.phone_number_id,
access_token: canalForm.value.access_token,
app_secret: canalForm.value.app_secret,
verify_token: canalForm.value.verify_token,
}
try {
await api.post('/app/umind/canales', {
tenant_id: tenantId.value,
tipo: canalForm.value.tipo,
credenciales,
activo: true,
})
showCanalForm.value = false
await cargarCanales()
} catch (e) {
error.value = e.message
}
}
async function toggleCanal(c) {
await api.put(`/app/umind/canales/${c.ID}`, { activo: !c.activo, credenciales: {} })
await cargarCanales()
}
async function eliminarCanal(c) {
if (!confirm(`¿Eliminar el canal ${c.tipo}?`)) return
await api.del(`/app/umind/canales/${c.ID}`)
await cargarCanales()
}
// ─── Chat de prueba ───────────────────────────────────────────────────────────
const chatSessionId = `staff-preview-${Math.random().toString(36).slice(2)}`
const chatMensajes = ref([])
const chatInput = ref('')
const chatEnviando = ref(false)
async function enviarChatPrueba() {
const texto = chatInput.value.trim()
if (!texto || chatEnviando.value) return
chatInput.value = ''
chatMensajes.value.push({ role: 'user', content: texto })
chatEnviando.value = true
try {
const r = await api.post('/app/umind/chat', {
tenant_id: tenantId.value,
session_id: chatSessionId,
mensaje: texto,
})
chatMensajes.value.push({ role: 'assistant', content: r.respuesta })
} catch (e) {
chatMensajes.value.push({ role: 'assistant', content: `⚠️ ${e.message}` })
} finally {
chatEnviando.value = false
}
}
const tabs = [
['conocimiento', 'Base de conocimiento'],
['herramientas', 'Herramientas'],
['canales', 'Canales'],
['chat', 'Chat de prueba'],
['conversaciones', 'Conversaciones'],
]
onMounted(async () => {
try {
await Promise.all([cargarTenant(), cargarDocumentos(), cargarSesiones(), cargarTools(), cargarCanales()])
} catch (e) {
error.value = e.message
}
})
</script>
<template>
<div>
<router-link to="/" class="text-sm text-gray-500 hover:text-gray-700">&larr; Tenants</router-link>
<div v-if="tenant" class="mt-2 mb-6">
<h1 class="text-xl font-semibold text-gray-800">{{ tenant.nombre }}</h1>
<p class="text-xs text-gray-500 mt-1">
site_key: <code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ tenant.site_key }}</code>
</p>
</div>
<p v-if="error" class="text-sm text-red-600 mb-4">{{ error }}</p>
<div class="border-b border-gray-200 mb-6 flex gap-6 text-sm overflow-x-auto">
<button
v-for="[key, label] in tabs"
:key="key"
class="pb-2 border-b-2 whitespace-nowrap"
:class="tab === key ? 'border-brand text-brand font-medium' : 'border-transparent text-gray-500'"
@click="tab = key"
>
{{ label }}
</button>
</div>
<!-- Base de conocimiento -->
<div v-if="tab === 'conocimiento'">
<form class="flex gap-2 mb-4" @submit.prevent="agregarFuente">
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm" />
<input v-model="maxPaginas" type="number" min="1" max="200" class="w-24 border border-gray-300 rounded-lg px-3 py-2 text-sm" title="Máximo de páginas a crawlear" />
<button type="submit" :disabled="ingestando" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg disabled:opacity-50">
{{ ingestando ? 'Agregando...' : 'Crawlear sitio' }}
</button>
</form>
<div class="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
<div v-if="documentos.length === 0" class="p-6 text-sm text-gray-500">Sin fuentes todavía.</div>
<div v-for="d in documentos" :key="d.ID" class="p-4 flex items-center justify-between">
<div>
<div class="text-sm text-gray-800">{{ d.origen }}</div>
<div class="text-xs text-gray-500 mt-0.5">
<span class="px-1.5 py-0.5 rounded" :class="estadoColor(d.estado)">{{ d.estado }}</span>
<span v-if="d.total_chunks"> · {{ d.total_chunks }} fragmentos</span>
<span v-if="d.error" class="text-red-600"> · {{ d.error }}</span>
</div>
</div>
<button class="text-red-500 hover:text-red-700 text-sm" @click="eliminarDocumento(d.ID)">Eliminar</button>
</div>
</div>
</div>
<!-- Herramientas -->
<div v-else-if="tab === 'herramientas'">
<div class="flex justify-between items-center mb-4">
<p class="text-xs text-gray-500">Máximo 10 tools activas por tenant.</p>
<button class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg" @click="nuevaTool">
+ Nueva tool
</button>
</div>
<div class="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
<div v-if="tools.length === 0" class="p-6 text-sm text-gray-500">Sin tools custom todavía.</div>
<div v-for="t in tools" :key="t.ID" class="p-4 flex items-center justify-between">
<div>
<div class="text-sm text-gray-800 font-mono">{{ t.nombre }}</div>
<div class="text-xs text-gray-500 mt-0.5">{{ t.descripcion }}</div>
<div class="text-xs text-gray-400 mt-0.5">
{{ t.url }}
<span v-if="t.auth_configurado" class="ml-1 text-green-600">· auth configurada</span>
<span v-if="!t.activa" class="ml-1 text-gray-400">· inactiva</span>
</div>
</div>
<div class="flex gap-3 text-sm shrink-0">
<button class="text-gray-500 hover:text-gray-800" @click="editarTool(t)">Editar</button>
<button class="text-red-500 hover:text-red-700" @click="eliminarTool(t)">Eliminar</button>
</div>
</div>
</div>
<div v-if="showToolForm" class="fixed inset-0 bg-black/30 flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
<div class="bg-white rounded-xl p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
<h2 class="font-semibold text-gray-800 mb-4">{{ editingTool ? 'Editar tool' : 'Nueva tool' }}</h2>
<form class="space-y-3" @submit.prevent="guardarTool">
<div>
<label class="text-xs text-gray-500">Nombre (identificador, ej: consultar_stock)</label>
<input v-model="toolForm.nombre" required pattern="[a-z][a-z0-9_]{2,63}" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<div>
<label class="text-xs text-gray-500">Descripción (esto lo lee el modelo para decidir cuándo usarla)</label>
<textarea v-model="toolForm.descripcion" rows="2" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"></textarea>
</div>
<div>
<label class="text-xs text-gray-500">URL del webhook (https)</label>
<input v-model="toolForm.url" type="url" required placeholder="https://..." class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div class="border border-gray-200 rounded-lg p-3 space-y-2">
<div class="flex items-center justify-between">
<label class="text-xs text-gray-500">Parámetros que completa el modelo</label>
<button type="button" class="text-xs text-brand" @click="agregarParametro">+ agregar</button>
</div>
<div v-for="(p, i) in toolForm.parametros" :key="i" class="flex gap-2 items-center">
<input v-model="p.nombre" placeholder="nombre" class="flex-1 border border-gray-300 rounded px-2 py-1 text-xs font-mono" />
<select v-model="p.tipo" class="border border-gray-300 rounded px-2 py-1 text-xs">
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
<input v-model="p.descripcion" placeholder="descripción" class="flex-1 border border-gray-300 rounded px-2 py-1 text-xs" />
<label class="text-xs text-gray-500 flex items-center gap-1">
<input v-model="p.requerido" type="checkbox" /> req.
</label>
<button type="button" class="text-red-400 text-xs" @click="quitarParametro(i)"></button>
</div>
<p v-if="toolForm.parametros.length === 0" class="text-xs text-gray-400">Sin parámetros.</p>
</div>
<div class="border border-gray-200 rounded-lg p-3 space-y-2">
<label class="text-xs text-gray-500">Autenticación saliente (opcional)</label>
<input v-model="toolForm.auth_header_nombre" placeholder="Nombre del header, ej: Authorization" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
<label class="flex items-center gap-2 text-xs text-gray-500">
<input v-model="toolForm.tocarAuth" type="checkbox" />
{{ editingTool ? 'Cambiar el valor del secreto' : 'Configurar valor' }}
</label>
<input
v-if="toolForm.tocarAuth"
v-model="toolForm.auth_header_valor"
type="password"
placeholder="Valor del header (ej: Bearer xxxx)"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
/>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600">
<input v-model="toolForm.activa" type="checkbox" /> Activa
</label>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="px-4 py-2 text-sm text-gray-500" @click="showToolForm = false">Cancelar</button>
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<!-- Canales -->
<div v-else-if="tab === 'canales'">
<div class="flex justify-end mb-4">
<button class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg" @click="nuevoCanal">
+ Nuevo canal
</button>
</div>
<div class="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
<div v-if="canales.length === 0" class="p-6 text-sm text-gray-500">Sin canales configurados.</div>
<div v-for="c in canales" :key="c.ID" class="p-4">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-gray-800 capitalize">{{ c.tipo }}</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'">
{{ c.activo ? 'activo' : 'inactivo' }}
</span>
</div>
<div class="flex gap-3 text-sm">
<button class="text-gray-500 hover:text-gray-800" @click="toggleCanal(c)">
{{ c.activo ? 'Desactivar' : 'Activar' }}
</button>
<button class="text-red-500 hover:text-red-700" @click="eliminarCanal(c)">Eliminar</button>
</div>
</div>
<p class="text-xs text-gray-500 mt-1 break-all">
Webhook: <code class="bg-gray-100 px-1 rounded">{{ c.webhook_url }}</code>
</p>
<p v-if="c.tipo === 'whatsapp'" class="text-xs text-gray-400 mt-1">
Registrá esta URL como "Callback URL" en Meta for Developers WhatsApp Configuration, con el mismo verify_token que pusiste acá.
</p>
<p v-if="c.ultimo_error" class="text-xs text-red-600 mt-1">{{ c.ultimo_error }}</p>
</div>
</div>
<div v-if="showCanalForm" class="fixed inset-0 bg-black/30 flex items-center justify-center p-4 z-50" @click.self="showCanalForm = false">
<div class="bg-white rounded-xl p-6 w-full max-w-md">
<h2 class="font-semibold text-gray-800 mb-4">Nuevo canal</h2>
<form class="space-y-3" @submit.prevent="guardarCanal">
<div>
<label class="text-xs text-gray-500">Tipo</label>
<select v-model="canalForm.tipo" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm">
<option value="telegram">Telegram</option>
<option value="whatsapp">WhatsApp Business</option>
</select>
</div>
<template v-if="canalForm.tipo === 'telegram'">
<div>
<label class="text-xs text-gray-500">Bot token (de @BotFather)</label>
<input v-model="canalForm.bot_token" type="password" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
</template>
<template v-else>
<div>
<label class="text-xs text-gray-500">Phone Number ID</label>
<input v-model="canalForm.phone_number_id" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label class="text-xs text-gray-500">Access Token</label>
<input v-model="canalForm.access_token" type="password" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label class="text-xs text-gray-500">App Secret</label>
<input v-model="canalForm.app_secret" type="password" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label class="text-xs text-gray-500">Verify Token (lo inventás vos, lo vas a usar en Meta)</label>
<input v-model="canalForm.verify_token" required class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
</template>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="px-4 py-2 text-sm text-gray-500" @click="showCanalForm = false">Cancelar</button>
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<!-- Chat de prueba -->
<div v-else-if="tab === 'chat'" class="bg-white rounded-xl border border-gray-200 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-gray-500">
Probá el agente tal cual lo va a ver un visitante usa la misma config de IA y las mismas tools/base de conocimiento del tenant.
</p>
<div
v-for="(m, i) in chatMensajes"
:key="i"
class="max-w-[80%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap"
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-gray-100 text-gray-800'"
>
{{ m.content }}
</div>
<p v-if="chatEnviando" class="text-xs text-gray-400">Pensando...</p>
</div>
<form class="flex gap-2" @submit.prevent="enviarChatPrueba">
<input v-model="chatInput" placeholder="Escribí un mensaje de prueba..." class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm" />
<button type="submit" :disabled="chatEnviando" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg disabled:opacity-50">
Enviar
</button>
</form>
</div>
<!-- Conversaciones -->
<div v-else class="grid grid-cols-3 gap-4">
<div class="col-span-1 bg-white rounded-xl border border-gray-200 divide-y divide-gray-100 max-h-[28rem] overflow-y-auto">
<div v-if="sesiones.length === 0" class="p-4 text-sm text-gray-500">Sin conversaciones.</div>
<button
v-for="s in sesiones"
:key="s.session_id"
class="w-full text-left p-3 hover:bg-gray-50 text-sm"
:class="sesionActiva === s.session_id ? 'bg-gray-50' : ''"
@click="verHistorial(s.session_id)"
>
<div class="text-gray-800 truncate">{{ s.content }}</div>
<div class="text-xs text-gray-400 mt-0.5">{{ s.session_id }}</div>
</button>
</div>
<div class="col-span-2 bg-white rounded-xl border border-gray-200 p-4 max-h-[28rem] overflow-y-auto space-y-2">
<p v-if="!sesionActiva" class="text-sm text-gray-500">Elegí una conversación de la izquierda.</p>
<div
v-for="m in historial"
:key="m.ID"
class="max-w-[80%] px-3 py-2 rounded-lg text-sm"
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-gray-100 text-gray-800'"
>
{{ m.content }}
</div>
</div>
</div>
</div>
</template>
+214
View File
@@ -0,0 +1,214 @@
<script setup>
import { onMounted, ref } from 'vue'
import { api } from '../lib/api.js'
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())
function vacio() {
return {
nombre: '',
dominios_permitidos: '',
ai_config_id: null,
tono: '',
mensaje_bienvenida: '',
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'),
])
tenants.value = t.items || []
aiConfigs.value = ai.registros || []
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
function nuevoTenant() {
editing.value = null
form.value = vacio()
showForm.value = true
}
function editarTenant(t) {
editing.value = 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,
activo: t.activo,
}
showForm.value = true
}
async function guardar() {
const payload = {
...form.value,
dominios_permitidos: form.value.dominios_permitidos
.split(',')
.map((d) => d.trim())
.filter(Boolean),
}
try {
if (editing.value) {
await api.put(`/app/umind/tenants/${editing.value.ID}`, payload)
} else {
await api.post('/app/umind/tenants', payload)
}
showForm.value = false
await cargar()
} catch (e) {
error.value = e.message
}
}
async function eliminar(t) {
if (!confirm(`¿Eliminar el tenant "${t.nombre}"? Esto no se puede deshacer.`)) return
try {
await api.del(`/app/umind/tenants/${t.ID}`)
await cargar()
} catch (e) {
error.value = e.message
}
}
onMounted(cargar)
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-semibold text-gray-800">Tenants</h1>
<button
class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg"
@click="nuevoTenant"
>
+ Nuevo tenant
</button>
</div>
<p v-if="error" class="text-sm text-red-600 mb-4">{{ error }}</p>
<p v-if="loading" class="text-sm text-gray-500">Cargando...</p>
<div v-else class="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
<div v-if="tenants.length === 0" class="p-6 text-sm text-gray-500">
Todavía no hay tenants. Creá el primero.
</div>
<div
v-for="t in tenants"
:key="t.ID"
class="p-4 flex items-center justify-between hover:bg-gray-50"
>
<div>
<router-link
:to="`/tenants/${t.ID}`"
class="font-medium text-gray-800 hover:text-brand"
>
{{ t.nombre }}
</router-link>
<div class="text-xs text-gray-500 mt-0.5">
{{ t.dominios_permitidos || 'sin dominios configurados' }}
<span
class="ml-2 px-1.5 py-0.5 rounded"
:class="t.activo ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
>
{{ t.activo ? 'activo' : 'inactivo' }}
</span>
</div>
</div>
<div class="flex gap-3 text-sm">
<button class="text-gray-500 hover:text-gray-800" @click="editarTenant(t)">Editar</button>
<button class="text-red-500 hover:text-red-700" @click="eliminar(t)">Eliminar</button>
</div>
</div>
</div>
<!-- Modal simple de alta/edición -->
<div
v-if="showForm"
class="fixed inset-0 bg-black/30 flex items-center justify-center p-4 z-50"
@click.self="showForm = false"
>
<div class="bg-white rounded-xl p-6 w-full max-w-lg">
<h2 class="font-semibold text-gray-800 mb-4">
{{ editing ? 'Editar tenant' : 'Nuevo tenant' }}
</h2>
<form class="space-y-3" @submit.prevent="guardar">
<div>
<label class="text-xs text-gray-500">Nombre</label>
<input
v-model="form.nombre"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div>
<label class="text-xs text-gray-500">Dominios permitidos (separados por coma)</label>
<input
v-model="form.dominios_permitidos"
placeholder="ejemplo.com, www.ejemplo.com"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div>
<label class="text-xs text-gray-500">Config de IA</label>
<select
v-model="form.ai_config_id"
class="w-full border border-gray-300 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">Tono / personalidad</label>
<textarea
v-model="form.tono"
rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
></textarea>
</div>
<div>
<label class="text-xs text-gray-500">Mensaje de bienvenida</label>
<input
v-model="form.mensaje_bienvenida"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
/>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600">
<input v-model="form.activo" type="checkbox" />
Activo
</label>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="px-4 py-2 text-sm text-gray-500" @click="showForm = false">
Cancelar
</button>
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">
Guardar
</button>
</div>
</form>
</div>
</div>
</div>
</template>