feat: conexiones OAuth (Gmail/Outlook) para el agente + rediseño del orquestador
Backend: - UmindConexion: cuenta de correo conectada por tenant vía OAuth2 (golang.org/x/oauth2, promovida de indirecta a directa), tokens cifrados en reposo con el mismo AES-GCM+APP_KEY que ya usan tools/canales. - Flujo completo: /app/umind/conexiones/conectar redirige a Google/Microsoft, /callback/:proveedor intercambia el code (state autoverificable por HMAC, sin tabla de estados pendientes), refresh on-demand antes de cada uso. - Dos tools nuevas para el agente (enviar_correo/leer_bandeja) que aparecen solo si el tenant tiene una conexión activa, vía Gmail API / Microsoft Graph directo (sin el SDK pesado de Google). - Requiere que el dueño del proyecto cree las apps OAuth en Google Cloud Console / Azure y cargue GOOGLE_OAUTH_CLIENT_ID/SECRET y MS_OAUTH_CLIENT_ID/SECRET — sin eso los botones de conectar fallan con un mensaje claro, no en silencio. Frontend: rediseño del orquestador — layout de sidebar fijo (reemplaza el navbar + lista de página completa), modo oscuro vía prefers-color-scheme, tabs en pill, y la nueva tab "Conexiones". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b2f6b518b6
commit
5ba41786d6
@@ -1,17 +1,14 @@
|
||||
<script setup>
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
</script>
|
||||
|
||||
<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">
|
||||
← Volver al panel
|
||||
</a>
|
||||
<div class="min-h-screen flex bg-gray-50 dark:bg-gray-950">
|
||||
<Sidebar />
|
||||
<main class="flex-1 min-w-0">
|
||||
<div class="max-w-4xl mx-auto px-8 py-10">
|
||||
<router-view />
|
||||
</div>
|
||||
</header>
|
||||
<main class="max-w-6xl mx-auto px-6 py-8">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
|
||||
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())
|
||||
|
||||
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)
|
||||
showForm.value = false
|
||||
await cargar()
|
||||
} else {
|
||||
const r = await api.post('/app/umind/tenants', payload)
|
||||
showForm.value = false
|
||||
await cargar()
|
||||
router.push(`/tenants/${r.id}`)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function eliminar(t) {
|
||||
if (!confirm(`¿Eliminar el tenant "${t.nombre}"? Esto no se puede deshacer.`)) return
|
||||
await api.del(`/app/umind/tenants/${t.ID}`)
|
||||
if (route.params.id === String(t.ID)) router.push('/')
|
||||
await cargar()
|
||||
}
|
||||
|
||||
defineExpose({ recargar: cargar })
|
||||
onMounted(cargar)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="w-64 shrink-0 h-screen sticky top-0 flex flex-col border-r border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
|
||||
<div class="px-4 py-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<router-link to="/" class="text-base font-semibold text-gray-800 dark:text-gray-100">
|
||||
uMind <span class="text-brand">Orquestador</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="px-3 pt-3">
|
||||
<button
|
||||
class="w-full bg-brand hover:bg-brand-dark text-white text-sm font-medium px-3 py-2 rounded-lg transition-colors"
|
||||
@click="nuevoTenant"
|
||||
>
|
||||
+ Nuevo tenant
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="px-3 pt-2 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
|
||||
<p v-if="loading" class="px-2 text-xs text-gray-400">Cargando...</p>
|
||||
<p v-else-if="tenants.length === 0" class="px-2 text-xs text-gray-400">Sin tenants todavía.</p>
|
||||
<div
|
||||
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'"
|
||||
>
|
||||
<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'"
|
||||
>
|
||||
<div class="truncate">{{ t.nombre }}</div>
|
||||
<div class="flex items-center gap-1 mt-0.5">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="t.activo ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'"></span>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ t.activo ? 'activo' : 'inactivo' }}</span>
|
||||
</div>
|
||||
</router-link>
|
||||
<div class="flex opacity-0 group-hover:opacity-100 transition-opacity pr-1.5 gap-0.5">
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
title="Editar"
|
||||
@click="editarTenant(t)"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-red-600"
|
||||
title="Eliminar"
|
||||
@click="eliminar(t)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Modal de alta/edición -->
|
||||
<div
|
||||
v-if="showForm"
|
||||
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
|
||||
@click.self="showForm = false"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-6 w-full max-w-lg border border-gray-200 dark:border-gray-800">
|
||||
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">
|
||||
{{ editing ? 'Editar tenant' : 'Nuevo tenant' }}
|
||||
</h2>
|
||||
<form class="space-y-3" @submit.prevent="guardar">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Nombre</label>
|
||||
<input
|
||||
v-model="form.nombre"
|
||||
required
|
||||
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">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 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>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<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 dark:text-gray-400" @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>
|
||||
</template>
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import TenantsList from './views/TenantsList.vue'
|
||||
import Home from './views/Home.vue'
|
||||
import TenantDetail from './views/TenantDetail.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/orchestrator/'),
|
||||
routes: [
|
||||
{ path: '/', name: 'tenants', component: TenantsList },
|
||||
{ path: '/', name: 'home', component: Home },
|
||||
{ path: '/tenants/:id', name: 'tenant-detail', component: TenantDetail, props: true },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center text-center py-24">
|
||||
<div class="text-4xl mb-4">💬</div>
|
||||
<h1 class="text-lg font-medium text-gray-700 dark:text-gray-200">Elegí un tenant de la izquierda</h1>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">o creá uno nuevo para empezar a configurar su agente.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
|
||||
const props = defineProps({ id: { type: String, required: true } })
|
||||
const tenantId = computed(() => Number(props.id))
|
||||
const route = useRoute()
|
||||
|
||||
const tenant = ref(null)
|
||||
const error = ref('')
|
||||
const tab = ref('conocimiento')
|
||||
const tab = ref(typeof route.query.tab === 'string' ? route.query.tab : 'conocimiento')
|
||||
|
||||
// ─── Base de conocimiento ───────────────────────────────────────────────────
|
||||
const documentos = ref([])
|
||||
@@ -51,11 +53,11 @@ async function eliminarDocumento(id) {
|
||||
}
|
||||
|
||||
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'))
|
||||
listo: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400',
|
||||
procesando: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400',
|
||||
pendiente: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
|
||||
error: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
|
||||
}[estado] || 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'))
|
||||
|
||||
// ─── Conversaciones ──────────────────────────────────────────────────────────
|
||||
const sesiones = ref([])
|
||||
@@ -81,14 +83,8 @@ const toolForm = ref(toolVacio())
|
||||
|
||||
function toolVacio() {
|
||||
return {
|
||||
nombre: '',
|
||||
descripcion: '',
|
||||
url: '',
|
||||
auth_header_nombre: '',
|
||||
auth_header_valor: '',
|
||||
tocarAuth: false,
|
||||
parametros: [],
|
||||
activa: true,
|
||||
nombre: '', descripcion: '', url: '', auth_header_nombre: '', auth_header_valor: '',
|
||||
tocarAuth: false, parametros: [], activa: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,14 +108,9 @@ function editarTool(t) {
|
||||
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,
|
||||
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
|
||||
}
|
||||
@@ -142,8 +133,6 @@ async function guardarTool() {
|
||||
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
|
||||
}
|
||||
@@ -173,12 +162,7 @@ const canalForm = ref(canalVacio())
|
||||
|
||||
function canalVacio() {
|
||||
return {
|
||||
tipo: 'telegram',
|
||||
bot_token: '',
|
||||
phone_number_id: '',
|
||||
access_token: '',
|
||||
app_secret: '',
|
||||
verify_token: '',
|
||||
tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,12 +187,7 @@ async function guardarCanal() {
|
||||
verify_token: canalForm.value.verify_token,
|
||||
}
|
||||
try {
|
||||
await api.post('/app/umind/canales', {
|
||||
tenant_id: tenantId.value,
|
||||
tipo: canalForm.value.tipo,
|
||||
credenciales,
|
||||
activo: true,
|
||||
})
|
||||
await api.post('/app/umind/canales', { tenant_id: tenantId.value, tipo: canalForm.value.tipo, credenciales, activo: true })
|
||||
showCanalForm.value = false
|
||||
await cargarCanales()
|
||||
} catch (e) {
|
||||
@@ -227,6 +206,25 @@ async function eliminarCanal(c) {
|
||||
await cargarCanales()
|
||||
}
|
||||
|
||||
// ─── Conexiones (correo, OAuth) ────────────────────────────────────────────────
|
||||
const conexiones = ref([])
|
||||
|
||||
async function cargarConexiones() {
|
||||
const r = await api.get(`/app/umind/conexiones?tenant_id=${props.id}`)
|
||||
conexiones.value = r.items || []
|
||||
}
|
||||
|
||||
function conectar(proveedor) {
|
||||
// Navegación normal (no fetch): el backend redirige a Google/Microsoft.
|
||||
window.location.href = `/app/umind/conexiones/conectar?tenant_id=${tenantId.value}&proveedor=${proveedor}`
|
||||
}
|
||||
|
||||
async function desconectar(c) {
|
||||
if (!confirm(`¿Desconectar la cuenta ${c.email || c.proveedor}?`)) return
|
||||
await api.del(`/app/umind/conexiones/${c.ID}`)
|
||||
await cargarConexiones()
|
||||
}
|
||||
|
||||
// ─── Chat de prueba ───────────────────────────────────────────────────────────
|
||||
const chatSessionId = `staff-preview-${Math.random().toString(36).slice(2)}`
|
||||
const chatMensajes = ref([])
|
||||
@@ -240,11 +238,7 @@ async function enviarChatPrueba() {
|
||||
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,
|
||||
})
|
||||
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}` })
|
||||
@@ -257,13 +251,14 @@ const tabs = [
|
||||
['conocimiento', 'Base de conocimiento'],
|
||||
['herramientas', 'Herramientas'],
|
||||
['canales', 'Canales'],
|
||||
['conexiones', 'Conexiones'],
|
||||
['chat', 'Chat de prueba'],
|
||||
['conversaciones', 'Conversaciones'],
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([cargarTenant(), cargarDocumentos(), cargarSesiones(), cargarTools(), cargarCanales()])
|
||||
await Promise.all([cargarTenant(), cargarDocumentos(), cargarSesiones(), cargarTools(), cargarCanales(), cargarConexiones()])
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
@@ -272,23 +267,23 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<router-link to="/" class="text-sm text-gray-500 hover:text-gray-700">← 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>
|
||||
<div v-if="tenant" class="mb-6">
|
||||
<h1 class="text-xl font-semibold text-gray-800 dark:text-gray-100">{{ tenant.nombre }}</h1>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
site_key: <code class="bg-gray-100 dark:bg-gray-800 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>
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||
|
||||
<div class="border-b border-gray-200 mb-6 flex gap-6 text-sm overflow-x-auto">
|
||||
<div class="flex gap-1.5 mb-6 overflow-x-auto pb-1">
|
||||
<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'"
|
||||
class="px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors"
|
||||
:class="tab === key
|
||||
? 'bg-brand text-white font-medium'
|
||||
: 'bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-800 hover:border-brand/50'"
|
||||
@click="tab = key"
|
||||
>
|
||||
{{ label }}
|
||||
@@ -298,21 +293,21 @@ onMounted(async () => {
|
||||
<!-- 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">
|
||||
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="flex-1 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 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 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 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 transition-colors">
|
||||
{{ 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 class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div v-if="documentos.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">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">
|
||||
<div class="text-sm text-gray-800 dark:text-gray-200">{{ d.origen }}</div>
|
||||
<div class="text-xs text-gray-500 dark: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>
|
||||
<span v-if="d.error" class="text-red-600 dark:text-red-400"> · {{ d.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-red-500 hover:text-red-700 text-sm" @click="eliminarDocumento(d.ID)">Eliminar</button>
|
||||
@@ -323,61 +318,61 @@ onMounted(async () => {
|
||||
<!-- 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">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">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 transition-colors" @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 class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div v-if="tools.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">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">
|
||||
<div class="text-sm text-gray-800 dark:text-gray-200 font-mono">{{ t.nombre }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t.descripcion }}</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{{ t.url }}
|
||||
<span v-if="t.auth_configurado" class="ml-1 text-green-600">· auth configurada</span>
|
||||
<span v-if="t.auth_configurado" class="ml-1 text-green-600 dark:text-green-400">· 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-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100" @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>
|
||||
<div v-if="showToolForm" class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
|
||||
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
|
||||
<h2 class="font-semibold text-gray-800 dark:text-gray-100 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" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">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 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>
|
||||
<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>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">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 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">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" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">URL del webhook (https)</label>
|
||||
<input v-model="toolForm.url" type="url" required placeholder="https://..." 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 class="border border-gray-200 rounded-lg p-3 space-y-2">
|
||||
<div class="border border-gray-200 dark:border-gray-800 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>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">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">
|
||||
<input v-model="p.nombre" placeholder="nombre" 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 px-2 py-1 text-xs font-mono" />
|
||||
<select v-model="p.tipo" class="border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 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.descripcion" placeholder="descripción" 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 px-2 py-1 text-xs" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400 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>
|
||||
@@ -385,10 +380,10 @@ onMounted(async () => {
|
||||
<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">
|
||||
<div class="border border-gray-200 dark:border-gray-800 rounded-lg p-3 space-y-2">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">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 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" />
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<input v-model="toolForm.tocarAuth" type="checkbox" />
|
||||
{{ editingTool ? 'Cambiar el valor del secreto' : 'Configurar valor' }}
|
||||
</label>
|
||||
@@ -397,15 +392,15 @@ onMounted(async () => {
|
||||
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"
|
||||
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>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-gray-600">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<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="button" class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400" @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>
|
||||
@@ -416,76 +411,74 @@ onMounted(async () => {
|
||||
<!-- 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">
|
||||
<button class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors" @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 class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div v-if="canales.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">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'">
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200 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 dark:bg-green-900/40 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'">
|
||||
{{ 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)">
|
||||
<button class="text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100" @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 class="text-xs text-gray-500 dark:text-gray-400 mt-1 break-all">
|
||||
Webhook: <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">{{ c.webhook_url }}</code>
|
||||
</p>
|
||||
<p v-if="c.tipo === 'whatsapp'" class="text-xs text-gray-400 mt-1">
|
||||
<p v-if="c.tipo === 'whatsapp'" class="text-xs text-gray-400 dark:text-gray-500 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>
|
||||
<p v-if="c.ultimo_error" class="text-xs text-red-600 dark:text-red-400 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>
|
||||
<div v-if="showCanalForm" class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" @click.self="showCanalForm = false">
|
||||
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-md">
|
||||
<h2 class="font-semibold text-gray-800 dark:text-gray-100 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">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Tipo</label>
|
||||
<select v-model="canalForm.tipo" 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="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" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Bot token (de @BotFather)</label>
|
||||
<input v-model="canalForm.bot_token" type="password" required 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>
|
||||
</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" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Phone Number ID</label>
|
||||
<input v-model="canalForm.phone_number_id" required 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">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" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">Access Token</label>
|
||||
<input v-model="canalForm.access_token" type="password" required 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">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" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">App Secret</label>
|
||||
<input v-model="canalForm.app_secret" type="password" required 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">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" />
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">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 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>
|
||||
</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="button" class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400" @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>
|
||||
@@ -493,25 +486,54 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conexiones (correo, OAuth) -->
|
||||
<div v-else-if="tab === 'conexiones'">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Conectá una cuenta de correo para que el agente pueda enviar y leer correo en nombre del negocio.
|
||||
Se soporta una cuenta activa a la vez.
|
||||
</p>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button class="border border-gray-300 dark:border-gray-700 hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('google')">
|
||||
Conectar Google
|
||||
</button>
|
||||
<button class="border border-gray-300 dark:border-gray-700 hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('microsoft')">
|
||||
Conectar Outlook
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div v-if="conexiones.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">Sin cuentas conectadas.</div>
|
||||
<div v-for="c in conexiones" :key="c.ID" class="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200 capitalize">{{ c.proveedor }}</span>
|
||||
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">{{ c.email }}</span>
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'">
|
||||
{{ c.activo ? 'activa' : 'inactiva' }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="text-red-500 hover:text-red-700 text-sm" @click="desconectar(c)">Desconectar</button>
|
||||
</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 v-else-if="tab === 'chat'" class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 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">
|
||||
<p v-if="chatMensajes.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
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'"
|
||||
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-100'"
|
||||
>
|
||||
{{ m.content }}
|
||||
</div>
|
||||
<p v-if="chatEnviando" class="text-xs text-gray-400">Pensando...</p>
|
||||
<p v-if="chatEnviando" class="text-xs text-gray-400 dark:text-gray-500">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">
|
||||
<input v-model="chatInput" placeholder="Escribí un mensaje de prueba..." 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" />
|
||||
<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 transition-colors">
|
||||
Enviar
|
||||
</button>
|
||||
</form>
|
||||
@@ -519,26 +541,26 @@ onMounted(async () => {
|
||||
|
||||
<!-- 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>
|
||||
<div class="col-span-1 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800 max-h-[28rem] overflow-y-auto">
|
||||
<div v-if="sesiones.length === 0" class="p-4 text-sm text-gray-500 dark:text-gray-400">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' : ''"
|
||||
class="w-full text-left p-3 hover:bg-gray-50 dark:hover:bg-gray-800 text-sm"
|
||||
:class="sesionActiva === s.session_id ? 'bg-gray-50 dark:bg-gray-800' : ''"
|
||||
@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>
|
||||
<div class="text-gray-800 dark:text-gray-200 truncate">{{ s.content }}</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 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 class="col-span-2 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 max-h-[28rem] overflow-y-auto space-y-2">
|
||||
<p v-if="!sesionActiva" class="text-sm text-gray-500 dark:text-gray-400">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'"
|
||||
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-100'"
|
||||
>
|
||||
{{ m.content }}
|
||||
</div>
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
|
||||
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())
|
||||
|
||||
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)
|
||||
showForm.value = false
|
||||
await cargar()
|
||||
} else {
|
||||
const r = await api.post('/app/umind/tenants', payload)
|
||||
showForm.value = false
|
||||
// Ir directo a configurar el tenant recién creado en vez de dejarlo
|
||||
// perdido en la lista — es lo primero que hay que hacer con él.
|
||||
router.push(`/tenants/${r.id}`)
|
||||
}
|
||||
} 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 cursor-pointer"
|
||||
@click="router.push(`/tenants/${t.ID}`)"
|
||||
>
|
||||
<div>
|
||||
<span class="font-medium text-gray-800">{{ t.nombre }}</span>
|
||||
<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 items-center gap-3 text-sm">
|
||||
<span class="text-brand font-medium">Configurar →</span>
|
||||
<button class="text-gray-500 hover:text-gray-800" @click.stop="editarTenant(t)">Editar</button>
|
||||
<button class="text-red-500 hover:text-red-700" @click.stop="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>
|
||||
Reference in New Issue
Block a user