Multi-tenant dentro de soft_usite, reutilizando la infraestructura ya existente (AiConfig, motor de function-calling del agente de Telegram) en vez de un servicio nuevo aparte: - UmindTenant: sitio/cliente con dominios permitidos, config de IA para el chat y personalidad/tono. - Ingesta: crawler simple (mismo dominio, N páginas) + chunking + embeddings (config global con módulo "umind_embeddings", pensada para OpenAI ya que Claude no ofrece embeddings) guardados como JSON, con búsqueda por similitud coseno en memoria (sin pgvector todavía). - Agente acotado: única herramienta buscar_conocimiento, sin acceso a nada interno — si no encuentra la respuesta, lo dice en vez de inventar. - Widget público (/widget/umind.js + /widget/:site_key/*), autenticado por site_key + validación de dominio (Origin/Referer), no por secreto, ya que la key viaja en el HTML público del sitio instalado. - Panel /app/umind: tenants, estado de ingesta, historial de conversaciones por sesión.
374 lines
23 KiB
HTML
374 lines
23 KiB
HTML
<!-- Vista: uMind — chat con IA embebible por tenant -->
|
|
<div x-data="umindApp()" x-init="init()" @keydown.escape.window="closeModal()" class="bg-white rounded-lg shadow">
|
|
|
|
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
|
|
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
|
|
</div>
|
|
|
|
<div class="container mx-auto p-6 w-full">
|
|
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold">uMind</h1>
|
|
<p class="text-xs text-slate-500 mt-0.5">Chat con IA embebible por sitio, con base de conocimiento propia (RAG).</p>
|
|
</div>
|
|
<button x-show="!tenantSeleccionado" @click="openAdd()"
|
|
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
|
|
style="background-color:#8eb02f"
|
|
onmouseover="this.style.backgroundColor='#6d8c24'"
|
|
onmouseout="this.style.backgroundColor='#8eb02f'">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
|
</svg>
|
|
Nuevo tenant
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="errorMsg" x-cloak class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
|
|
<div x-show="successMsg" x-cloak class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700" x-text="successMsg"></div>
|
|
|
|
<!-- ─── Lista de tenants ────────────────────────────────────────────── -->
|
|
<div x-show="!tenantSeleccionado">
|
|
<div class="overflow-x-auto">
|
|
<table class="table-auto w-full text-sm">
|
|
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
|
<tr>
|
|
<th class="py-2 px-3">Nombre</th>
|
|
<th class="py-2 px-3">Dominios</th>
|
|
<th class="py-2 px-3">Modelo (chat)</th>
|
|
<th class="py-2 px-3">Estado</th>
|
|
<th class="py-2 px-3 text-right">Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100">
|
|
<template x-if="tenants.length === 0">
|
|
<tr><td colspan="5" class="py-8 text-center text-gray-400">Sin tenants registrados</td></tr>
|
|
</template>
|
|
<template x-for="t in tenants" :key="t.ID">
|
|
<tr class="hover:bg-gray-50 transition">
|
|
<td class="py-2 px-3 font-medium cursor-pointer" @click="abrirTenant(t)" x-text="t.nombre"></td>
|
|
<td class="py-2 px-3 text-xs text-gray-500" x-text="t.dominios_permitidos"></td>
|
|
<td class="py-2 px-3 text-xs text-gray-500" x-text="t.ai_config_id ? ('#' + t.ai_config_id) : '—'"></td>
|
|
<td class="py-2 px-3">
|
|
<span :class="t.activo ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
|
class="px-2 py-0.5 rounded-full text-xs font-semibold"
|
|
x-text="t.activo ? 'Activo' : 'Inactivo'"></span>
|
|
</td>
|
|
<td class="py-2 px-3 text-right">
|
|
<div class="flex justify-end gap-2">
|
|
<button @click="abrirTenant(t)" class="text-xs text-green-600 hover:text-green-800 font-medium transition">Ver</button>
|
|
<button @click="openEdit(t)" class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">Editar</button>
|
|
<button @click="confirmDelete(t.ID)" class="text-xs text-red-500 hover:text-red-700 font-medium transition">Eliminar</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ─── Detalle de un tenant ───────────────────────────────────────── -->
|
|
<div x-show="tenantSeleccionado" x-cloak>
|
|
<button @click="tenantSeleccionado=null" class="text-xs text-gray-500 hover:text-gray-700 mb-4">← Volver a la lista</button>
|
|
|
|
<template x-if="tenantSeleccionado">
|
|
<div>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-lg font-bold" x-text="tenantSeleccionado.nombre"></h2>
|
|
<button @click="openEdit(tenantSeleccionado)" class="text-xs text-blue-600 hover:text-blue-800 font-medium">Editar</button>
|
|
</div>
|
|
|
|
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3 mb-6">
|
|
<p class="text-xs font-medium text-gray-600 mb-1">Código para instalar en el sitio:</p>
|
|
<div class="flex gap-2">
|
|
<code class="flex-1 text-xs bg-white border border-gray-200 rounded px-2 py-1.5 overflow-x-auto" x-text="snippetEmbed(tenantSeleccionado)"></code>
|
|
<button @click="copiar(snippetEmbed(tenantSeleccionado))" class="px-3 py-1.5 text-xs border border-gray-300 rounded-lg hover:bg-gray-50">Copiar</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-1 border-b border-gray-200 mb-5">
|
|
<button @click="subtab='conocimiento'; loadDocumentos()"
|
|
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition"
|
|
:class="subtab === 'conocimiento' ? 'border-[#8eb02f] text-[#5a7a1e]' : 'border-transparent text-gray-500 hover:text-gray-700'">
|
|
Base de conocimiento
|
|
</button>
|
|
<button @click="subtab='conversaciones'; loadSesiones()"
|
|
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition"
|
|
:class="subtab === 'conversaciones' ? 'border-[#8eb02f] text-[#5a7a1e]' : 'border-transparent text-gray-500 hover:text-gray-700'">
|
|
Conversaciones
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Conocimiento -->
|
|
<div x-show="subtab === 'conocimiento'">
|
|
<form @submit.prevent="agregarDocumento()" class="flex gap-2 mb-4">
|
|
<input x-model="nuevaURL" type="url" required placeholder="https://sitio.com — se crawlea automáticamente"
|
|
class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
|
|
<input x-model.number="nuevoMaxPaginas" type="number" min="1" max="200" placeholder="máx. páginas"
|
|
class="w-32 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
|
|
<button type="submit" :disabled="ingestando"
|
|
class="px-4 py-2 text-sm text-white rounded-lg" style="background-color:#8eb02f">
|
|
<span x-text="ingestando ? 'Enviando...' : 'Ingestar'"></span>
|
|
</button>
|
|
</form>
|
|
<table class="table-auto w-full text-sm">
|
|
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
|
|
<tr><th class="py-2 px-3">Fuente</th><th class="py-2 px-3">Estado</th><th class="py-2 px-3">Fragmentos</th><th class="py-2 px-3 text-right">Acciones</th></tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100">
|
|
<template x-if="documentos.length === 0">
|
|
<tr><td colspan="4" class="py-6 text-center text-gray-400">Sin fuentes cargadas todavía</td></tr>
|
|
</template>
|
|
<template x-for="d in documentos" :key="d.ID">
|
|
<tr>
|
|
<td class="py-2 px-3 text-xs break-all" x-text="d.origen"></td>
|
|
<td class="py-2 px-3">
|
|
<span class="px-2 py-0.5 rounded-full text-xs font-semibold"
|
|
:class="{
|
|
'bg-green-100 text-green-700': d.estado === 'listo',
|
|
'bg-yellow-100 text-yellow-700': d.estado === 'procesando' || d.estado === 'pendiente',
|
|
'bg-red-100 text-red-700': d.estado === 'error'
|
|
}" x-text="d.estado"></span>
|
|
<p x-show="d.error" class="text-[10px] text-red-500 mt-1" x-text="d.error"></p>
|
|
</td>
|
|
<td class="py-2 px-3 text-xs" x-text="d.total_chunks"></td>
|
|
<td class="py-2 px-3 text-right">
|
|
<button @click="eliminarDocumento(d.ID)" class="text-xs text-red-500 hover:text-red-700 font-medium">Eliminar</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Conversaciones -->
|
|
<div x-show="subtab === 'conversaciones'">
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div class="md:col-span-1 border border-gray-200 rounded-lg overflow-hidden">
|
|
<template x-if="sesiones.length === 0">
|
|
<p class="text-xs text-gray-400 text-center py-6">Sin conversaciones todavía</p>
|
|
</template>
|
|
<template x-for="s in sesiones" :key="s.session_id">
|
|
<div @click="verHistorial(s.session_id)"
|
|
class="p-3 border-b border-gray-100 cursor-pointer hover:bg-gray-50 text-xs"
|
|
:class="sessionActiva === s.session_id ? 'bg-gray-50' : ''">
|
|
<p class="font-mono text-gray-400" x-text="s.session_id.slice(0,10)+'…'"></p>
|
|
<p class="text-gray-600 truncate" x-text="s.content"></p>
|
|
<p class="text-[10px] text-gray-400 mt-1" x-text="new Date(s.CreatedAt).toLocaleString()"></p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="md:col-span-2 border border-gray-200 rounded-lg p-3 max-h-96 overflow-y-auto">
|
|
<template x-if="!sessionActiva">
|
|
<p class="text-xs text-gray-400 text-center py-6">Selecciona una conversación</p>
|
|
</template>
|
|
<template x-for="(m, idx) in historial" :key="idx">
|
|
<div class="mb-2 text-xs">
|
|
<span class="font-semibold" :class="m.role === 'user' ? 'text-[#5a7a1e]' : 'text-gray-500'" x-text="m.role === 'user' ? 'Visitante:' : 'Bot:'"></span>
|
|
<span x-text="m.content"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal crear / editar tenant -->
|
|
<div x-show="showModal" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
|
|
<div @click.outside="closeModal()" class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
|
|
<h2 class="text-lg font-bold mb-4" x-text="editItem ? 'Editar tenant' : 'Nuevo tenant'"></h2>
|
|
<form @submit.prevent="save()">
|
|
<div class="grid grid-cols-1 gap-4">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre *</label>
|
|
<input x-model="form.nombre" type="text" required placeholder="Ej: U-Site"
|
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Dominios permitidos * (uno por línea)</label>
|
|
<textarea x-model="dominiosText" rows="2" placeholder="u-site.app www.u-site.app"
|
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"></textarea>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Config de IA (chat)</label>
|
|
<select x-model="form.ai_config_id" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
|
|
<option value="">Sin asignar</option>
|
|
<template x-for="opt in aiConfigs" :key="opt.ID">
|
|
<option :value="opt.ID" x-text="opt.nombre + ' (' + opt.provider + ')'"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Tono / personalidad</label>
|
|
<textarea x-model="form.tono" rows="2" placeholder="Ej: Cercano, informal, usa emojis con moderación."
|
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"></textarea>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Mensaje de bienvenida</label>
|
|
<input x-model="form.mensaje_bienvenida" type="text" placeholder="¡Hola! ¿En qué puedo ayudarte?"
|
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<input x-model="form.activo" type="checkbox" id="umind_activo" class="rounded">
|
|
<label for="umind_activo" class="text-sm text-gray-700">Activo</label>
|
|
</div>
|
|
</div>
|
|
<div x-show="formError" class="mt-3 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-600" x-text="formError"></div>
|
|
<div class="flex justify-end gap-3 mt-5">
|
|
<button type="button" @click="closeModal()" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancelar</button>
|
|
<button type="submit" :disabled="saving" class="px-4 py-2 text-sm text-white rounded-lg transition disabled:opacity-50" style="background-color:#8eb02f">
|
|
<span x-text="saving ? 'Guardando...' : (editItem ? 'Actualizar' : 'Crear')"></span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal confirmar eliminación -->
|
|
<div x-show="deleteId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
|
|
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
|
|
<p class="text-gray-700 font-semibold mb-1">¿Eliminar tenant?</p>
|
|
<p class="text-xs text-gray-500 mb-5">Se borra también su base de conocimiento. El widget instalado en el sitio dejará de funcionar.</p>
|
|
<div class="flex justify-center gap-3">
|
|
<button @click="deleteId = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
|
|
<button @click="doDelete()" :disabled="saving" class="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition disabled:opacity-50">
|
|
<span x-text="saving ? 'Eliminando...' : 'Eliminar'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function umindApp() {
|
|
return {
|
|
loading: false, saving: false, ingestando: false,
|
|
tenants: [], aiConfigs: [],
|
|
tenantSeleccionado: null, subtab: 'conocimiento',
|
|
documentos: [], nuevaURL: '', nuevoMaxPaginas: 30,
|
|
sesiones: [], sessionActiva: null, historial: [],
|
|
showModal: false, editItem: null, deleteId: null,
|
|
errorMsg: '', successMsg: '', formError: '',
|
|
dominiosText: '',
|
|
form: { nombre: '', ai_config_id: '', tono: '', mensaje_bienvenida: '', activo: true },
|
|
|
|
async init() {
|
|
await this.loadTenants();
|
|
const r = await fetch('/app/api/ai-config/select');
|
|
const data = await r.json();
|
|
this.aiConfigs = data.registros || [];
|
|
},
|
|
|
|
async loadTenants() {
|
|
this.loading = true; this.errorMsg = '';
|
|
const res = await fetch('/app/umind/tenants');
|
|
const data = await res.json();
|
|
this.loading = false;
|
|
if (!res.ok) { this.errorMsg = data.error || 'Error cargando datos'; return }
|
|
this.tenants = data.items || [];
|
|
},
|
|
|
|
abrirTenant(t) { this.tenantSeleccionado = t; this.subtab = 'conocimiento'; this.loadDocumentos(); },
|
|
|
|
snippetEmbed(t) {
|
|
return `<script src="${window.location.origin}/widget/umind.js" data-site="${t.site_key || ''}" defer></scr` + `ipt>`;
|
|
},
|
|
|
|
openAdd() {
|
|
this.editItem = null;
|
|
this.form = { nombre: '', ai_config_id: '', tono: '', mensaje_bienvenida: '', activo: true };
|
|
this.dominiosText = '';
|
|
this.formError = '';
|
|
this.showModal = true;
|
|
},
|
|
openEdit(t) {
|
|
this.editItem = t;
|
|
this.form = { nombre: t.nombre, ai_config_id: t.ai_config_id || '', tono: t.tono || '', mensaje_bienvenida: t.mensaje_bienvenida || '', activo: t.activo };
|
|
this.dominiosText = (t.dominios_permitidos || '').split(',').map(s => s.trim()).filter(s => s).join('\n');
|
|
this.formError = '';
|
|
this.showModal = true;
|
|
},
|
|
closeModal() { this.showModal = false; this.editItem = null; this.formError = ''; },
|
|
|
|
async save() {
|
|
this.saving = true; this.formError = '';
|
|
const dominios = this.dominiosText.split(/[\n,]/).map(s => s.trim()).filter(s => s);
|
|
const payload = { ...this.form, dominios_permitidos: dominios, ai_config_id: this.form.ai_config_id ? Number(this.form.ai_config_id) : null };
|
|
const url = this.editItem ? `/app/umind/tenants/${this.editItem.ID}` : '/app/umind/tenants';
|
|
const method = this.editItem ? 'PUT' : 'POST';
|
|
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
|
const data = await res.json();
|
|
this.saving = false;
|
|
if (!res.ok) { this.formError = data.error || 'Error guardando'; return }
|
|
this.closeModal();
|
|
this.successMsg = this.editItem ? 'Tenant actualizado' : 'Tenant creado — copia el código de instalación desde su ficha';
|
|
setTimeout(() => this.successMsg = '', 4000);
|
|
await this.loadTenants();
|
|
if (this.tenantSeleccionado) {
|
|
const actualizado = this.tenants.find(x => x.ID === this.tenantSeleccionado.ID);
|
|
if (actualizado) this.tenantSeleccionado = actualizado;
|
|
}
|
|
},
|
|
|
|
confirmDelete(id) { this.deleteId = id; },
|
|
async doDelete() {
|
|
this.saving = true;
|
|
const res = await fetch(`/app/umind/tenants/${this.deleteId}`, { method: 'DELETE' });
|
|
this.saving = false; this.deleteId = null;
|
|
if (!res.ok) { this.errorMsg = 'Error eliminando'; return }
|
|
if (this.tenantSeleccionado) this.tenantSeleccionado = null;
|
|
await this.loadTenants();
|
|
},
|
|
|
|
async loadDocumentos() {
|
|
if (!this.tenantSeleccionado) return;
|
|
const res = await fetch(`/app/umind/documentos?tenant_id=${this.tenantSeleccionado.ID}`);
|
|
const data = await res.json();
|
|
this.documentos = data.items || [];
|
|
},
|
|
async agregarDocumento() {
|
|
if (!this.nuevaURL.trim()) return;
|
|
this.ingestando = true;
|
|
const res = await fetch('/app/umind/documentos', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tenant_id: this.tenantSeleccionado.ID, url: this.nuevaURL.trim(), max_paginas: this.nuevoMaxPaginas || 30 })
|
|
});
|
|
const data = await res.json();
|
|
this.ingestando = false;
|
|
if (!res.ok) { this.errorMsg = data.error || 'Error iniciando la ingesta'; return }
|
|
this.nuevaURL = '';
|
|
this.successMsg = 'Ingesta iniciada — puede tardar unos minutos, actualiza la lista para ver el progreso';
|
|
setTimeout(() => this.successMsg = '', 4000);
|
|
await this.loadDocumentos();
|
|
},
|
|
async eliminarDocumento(id) {
|
|
await fetch(`/app/umind/documentos/${id}`, { method: 'DELETE' });
|
|
await this.loadDocumentos();
|
|
},
|
|
|
|
async loadSesiones() {
|
|
if (!this.tenantSeleccionado) return;
|
|
const res = await fetch(`/app/umind/sesiones?tenant_id=${this.tenantSeleccionado.ID}`);
|
|
const data = await res.json();
|
|
this.sesiones = data.items || [];
|
|
},
|
|
async verHistorial(sessionId) {
|
|
this.sessionActiva = sessionId;
|
|
const res = await fetch(`/app/umind/historial?tenant_id=${this.tenantSeleccionado.ID}&session_id=${sessionId}`);
|
|
const data = await res.json();
|
|
this.historial = data.items || [];
|
|
},
|
|
|
|
copiar(texto) {
|
|
navigator.clipboard.writeText(texto);
|
|
this.successMsg = 'Copiado al portapapeles';
|
|
setTimeout(() => this.successMsg = '', 2000);
|
|
}
|
|
}
|
|
}
|
|
</script>
|