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:
Lizandro Guarnizo
2026-08-12 10:25:13 -05:00
co-authored by Claude Sonnet 5
parent b2f6b518b6
commit 5ba41786d6
27 changed files with 1254 additions and 396 deletions
+8
View File
@@ -51,6 +51,14 @@ token:
app_jwt_secret: SECRET_APP
api_jwt_secret: SECRET_API
expires_in: 31536000
# Conexiones OAuth de uMind (Gmail/Outlook) — vacío hasta crear las apps en
# Google Cloud Console / Azure. Se completan por variable de entorno
# (GOOGLE_OAUTH_CLIENT_ID, etc.), no acá.
oauth:
google_client_id: ""
google_client_secret: ""
ms_client_id: ""
ms_client_secret: ""
jwt:
app:
secret: SECRET_APP
+8
View File
@@ -55,6 +55,14 @@ token:
app_jwt_secret: SECRET_APP
api_jwt_secret: SECRET_API
expires_in: 31536000
# Conexiones OAuth de uMind (Gmail/Outlook) — vacío hasta crear las apps en
# Google Cloud Console / Azure. Se completan por variable de entorno
# (GOOGLE_OAUTH_CLIENT_ID, etc.), no acá.
oauth:
google_client_id: ""
google_client_secret: ""
ms_client_id: ""
ms_client_secret: ""
jwt:
app:
secret: SECRET_APP
+1
View File
@@ -28,6 +28,7 @@ type AppConfig struct {
Server ServerConfig `yaml:"server"`
Log LogConfig `yaml:"log"`
Token Token `yaml:"token"`
OAuth OAuthConfig `yaml:"oauth"`
Profiler ProfilerConfig `yaml:"profiler"`
Flash *flash.Flash
ConfigFile string
+12
View File
@@ -0,0 +1,12 @@
package config
// OAuthConfig trae las credenciales de las apps OAuth para conectar cuentas
// de correo (uMind: enviar/leer correo en nombre de un tenant). Sin
// env-default, igual que los secretos JWT — se cargan por variable de
// entorno, no quedan escritos en config.yml.
type OAuthConfig struct {
GoogleClientID string `mapstructure:"GOOGLE_OAUTH_CLIENT_ID" yaml:"google_client_id" env:"GOOGLE_OAUTH_CLIENT_ID"`
GoogleClientSecret string `mapstructure:"GOOGLE_OAUTH_CLIENT_SECRET" yaml:"google_client_secret" env:"GOOGLE_OAUTH_CLIENT_SECRET"`
MSClientID string `mapstructure:"MS_OAUTH_CLIENT_ID" yaml:"ms_client_id" env:"MS_OAUTH_CLIENT_ID"`
MSClientSecret string `mapstructure:"MS_OAUTH_CLIENT_SECRET" yaml:"ms_client_secret" env:"MS_OAUTH_CLIENT_SECRET"`
}
+2 -1
View File
@@ -57,11 +57,13 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
go.mongodb.org/mongo-driver v1.17.9
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.23.0
gorm.io/driver/sqlite v1.5.6
gorm.io/driver/sqlserver v1.5.3
)
require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
@@ -144,7 +146,6 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
+2
View File
@@ -33,6 +33,8 @@ cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvf
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
+1
View File
@@ -153,6 +153,7 @@ func main() {
&models.UmindMensaje{},
&models.UmindHerramienta{},
&models.UmindCanal{},
&models.UmindConexion{},
// API Keys de /api/v2 (token + IP obligatoria + scopes)
&models.ApiKey{},
}
+9 -12
View File
@@ -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">
&larr; Volver al panel
</a>
</div>
</header>
<main class="max-w-6xl mx-auto px-6 py-8">
<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>
</main>
</div>
</template>
+228
View File
@@ -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>
+2 -2
View File
@@ -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 },
],
})
+7
View File
@@ -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>
+156 -134
View File
@@ -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">&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>
<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>
-218
View File
@@ -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 &rarr;</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>
+1
View File
@@ -1,5 +1,6 @@
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
darkMode: 'media',
theme: {
extend: {
colors: {
+75
View File
@@ -0,0 +1,75 @@
package models
import (
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// UmindConexion es una cuenta de correo real (Gmail u Outlook) conectada por
// OAuth a un tenant, para que el agente pueda enviar y leer correo en su
// nombre (tools enviar_correo/leer_bandeja, ver pkg/services/umind_agent_service.go).
// AccessTokenEnc/RefreshTokenEnc viajan cifrados en reposo (ver
// pkg/services/umind_secrets.go) — a diferencia del site_key del widget,
// estos SÍ son secretos: quien los tenga puede leer/mandar correo como el
// dueño de la cuenta.
type UmindConexion struct {
gorm.Model
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
Proveedor string `json:"proveedor" gorm:"column:proveedor;size:20;not null"` // google | microsoft
Email string `json:"email" gorm:"column:email;size:255"`
AccessTokenEnc string `json:"-" gorm:"column:access_token_enc;type:text"`
RefreshTokenEnc string `json:"-" gorm:"column:refresh_token_enc;type:text"`
ExpiraEn time.Time `json:"expira_en" gorm:"column:expira_en"`
Scopes string `json:"scopes" gorm:"column:scopes;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (UmindConexion) TableName() string { return "umind_conexiones" }
func CreateUmindConexion(c *UmindConexion) error {
return app.Http.Database.DB.Create(c).Error
}
func GetUmindConexionesByTenant(tenantID uint) ([]UmindConexion, error) {
var items []UmindConexion
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).Order("id DESC").Find(&items).Error
return items, err
}
func GetUmindConexionByID(id uint) (*UmindConexion, error) {
var c UmindConexion
if err := app.Http.Database.DB.First(&c, id).Error; err != nil {
return nil, err
}
return &c, nil
}
// GetUmindConexionActiva retorna la primera conexión activa del tenant —
// hoy se soporta una sola cuenta de correo conectada por tenant, no una
// bandeja por proveedor a la vez.
func GetUmindConexionActiva(tenantID uint) (*UmindConexion, error) {
var c UmindConexion
err := app.Http.Database.DB.Where("tenant_id = ? AND activo = ?", tenantID, true).First(&c).Error
if err != nil {
return nil, err
}
return &c, nil
}
// DesactivarConexionesDelTenant se llama antes de crear una conexión nueva —
// hoy se soporta una sola cuenta de correo activa por tenant a la vez.
func DesactivarConexionesDelTenant(tenantID uint) error {
return app.Http.Database.DB.Model(&UmindConexion{}).
Where("tenant_id = ? AND activo = ?", tenantID, true).
Update("activo", false).Error
}
func UpdateUmindConexion(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindConexion{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteUmindConexion(id uint) error {
return app.Http.Database.DB.Delete(&UmindConexion{}, id).Error
}
+100
View File
@@ -82,9 +82,51 @@ func umindTools(tenantID uint) []agentTool {
},
})
}
if conexion, err := models.GetUmindConexionActiva(tenantID); err == nil && conexion != nil {
tools = append(tools, umindEmailTools()...)
}
return tools
}
// umindEmailTools son las tools de correo, disponibles solo cuando el
// tenant tiene una cuenta conectada (UmindConexion activa) — nombres
// genéricos porque al modelo no le importa si detrás hay Gmail u Outlook.
func umindEmailTools() []agentTool {
return []agentTool{
{
Type: "function",
Function: agentToolFunc{
Name: "enviar_correo",
Description: "Envía un correo electrónico desde la cuenta de correo conectada del negocio.",
Parameters: agentToolParam{
Type: "object",
Properties: map[string]agentToolParam{
"destinatario": {Type: "string", Description: "Email del destinatario"},
"asunto": {Type: "string", Description: "Asunto del correo"},
"cuerpo": {Type: "string", Description: "Cuerpo del correo en texto plano"},
},
Required: []string{"destinatario", "asunto", "cuerpo"},
},
},
},
{
Type: "function",
Function: agentToolFunc{
Name: "leer_bandeja",
Description: "Busca correos recibidos en la bandeja conectada del negocio (ej. revisar si llegó un comprobante o la respuesta de un cliente).",
Parameters: agentToolParam{
Type: "object",
Properties: map[string]agentToolParam{
"consulta": {Type: "string", Description: "Qué buscar: remitente, palabras clave del asunto o del cuerpo"},
},
Required: []string{"consulta"},
},
},
},
}
}
// executeUmindTool ejecuta buscar_conocimiento (RAG interno) o, si el nombre
// no matchea, busca una UmindHerramienta custom del tenant y hace el POST al
// webhook configurado. Devuelve el resultado ya serializado, en el mismo
@@ -111,6 +153,10 @@ func executeUmindTool(tenantID uint, name string, args map[string]interface{}) s
return string(b)
}
if name == "enviar_correo" || name == "leer_bandeja" {
return executeUmindEmailTool(tenantID, name, args)
}
herramienta, err := models.GetUmindHerramientaByNombre(tenantID, name)
if err != nil {
return fmt.Sprintf(`{"error": "herramienta desconocida: %s"}`, name)
@@ -131,6 +177,60 @@ func executeUmindTool(tenantID uint, name string, args map[string]interface{}) s
return resultado
}
// executeUmindEmailTool despacha enviar_correo/leer_bandeja a Gmail o
// Microsoft Graph según el proveedor de la conexión activa del tenant,
// refrescando el token primero si hace falta.
func executeUmindEmailTool(tenantID uint, name string, args map[string]interface{}) string {
conexion, err := models.GetUmindConexionActiva(tenantID)
if err != nil {
return `{"error": "no hay ninguna cuenta de correo conectada"}`
}
if err := RefrescarSiVence(conexion); err != nil {
log.Printf("[UMIND] Error refrescando token OAuth (conexión %d): %v", conexion.ID, err)
return `{"error": "no se pudo usar la cuenta de correo conectada, intenta más tarde"}`
}
switch name {
case "enviar_correo":
destinatario, _ := args["destinatario"].(string)
asunto, _ := args["asunto"].(string)
cuerpo, _ := args["cuerpo"].(string)
if strings.TrimSpace(destinatario) == "" || strings.TrimSpace(cuerpo) == "" {
return `{"error": "destinatario y cuerpo son requeridos"}`
}
var envErr error
if conexion.Proveedor == UmindOAuthGoogle {
envErr = EnviarCorreoGoogle(conexion, destinatario, asunto, cuerpo)
} else {
envErr = EnviarCorreoMicrosoft(conexion, destinatario, asunto, cuerpo)
}
if envErr != nil {
log.Printf("[UMIND] Error enviando correo (tenant %d): %v", tenantID, envErr)
return `{"error": "no se pudo enviar el correo"}`
}
return `{"ok": true}`
case "leer_bandeja":
consulta, _ := args["consulta"].(string)
var resultados []CorreoResumen
var lecErr error
if conexion.Proveedor == UmindOAuthGoogle {
resultados, lecErr = LeerBandejaGoogle(conexion, consulta, 5)
} else {
resultados, lecErr = LeerBandejaMicrosoft(conexion, consulta, 5)
}
if lecErr != nil {
log.Printf("[UMIND] Error leyendo bandeja (tenant %d): %v", tenantID, lecErr)
return `{"error": "no se pudo leer la bandeja"}`
}
b, _ := json.Marshal(map[string]interface{}{"resultados": resultados})
return string(b)
default:
return `{"error": "herramienta desconocida"}`
}
}
// ProcessWidgetMessage procesa un mensaje del widget de uMind y devuelve la
// respuesta del agente. Es el equivalente de ProcessAgentMessage pero
// multi-tenant y con un toolset acotado a RAG (sin herramientas internas).
+116
View File
@@ -0,0 +1,116 @@
package services
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// EnviarCorreoGoogle manda un correo de texto plano vía Gmail API en nombre
// de la cuenta conectada. Asume que conexion ya pasó por RefrescarSiVence.
func EnviarCorreoGoogle(conexion *models.UmindConexion, destinatario, asunto, cuerpo string) error {
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
if err != nil {
return fmt.Errorf("no se pudo descifrar el access token: %w", err)
}
mime := fmt.Sprintf("To: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\n%s",
destinatario, asunto, cuerpo)
raw := base64.RawURLEncoding.EncodeToString([]byte(mime))
body, _ := json.Marshal(map[string]string{"raw": raw})
req, err := http.NewRequest(http.MethodPost, "https://gmail.googleapis.com/gmail/v1/users/me/messages/send", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := umindOAuthHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("no se pudo contactar Gmail: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("Gmail respondió %d: %s", resp.StatusCode, string(detalle))
}
return nil
}
// LeerBandejaGoogle busca mensajes en Gmail (sintaxis de búsqueda de Gmail,
// ej: "from:cliente@ejemplo.com") y devuelve un resumen liviano de cada uno.
func LeerBandejaGoogle(conexion *models.UmindConexion, consulta string, limite int) ([]CorreoResumen, error) {
if limite <= 0 || limite > 10 {
limite = 10
}
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
if err != nil {
return nil, fmt.Errorf("no se pudo descifrar el access token: %w", err)
}
listURL := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?q=%s&maxResults=%d",
url.QueryEscape(consulta), limite)
var lista struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}
if err := gmailGetJSON(listURL, accessToken, &lista); err != nil {
return nil, err
}
resultados := make([]CorreoResumen, 0, len(lista.Messages))
for _, m := range lista.Messages {
detalleURL := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages/%s?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date", m.ID)
var msg struct {
Snippet string `json:"snippet"`
Payload struct {
Headers []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"headers"`
} `json:"payload"`
}
if err := gmailGetJSON(detalleURL, accessToken, &msg); err != nil {
continue // un mensaje individual que falla no debe tirar abajo toda la búsqueda
}
r := CorreoResumen{Extracto: msg.Snippet}
for _, h := range msg.Payload.Headers {
switch h.Name {
case "From":
r.De = h.Value
case "Subject":
r.Asunto = h.Value
case "Date":
r.Fecha = h.Value
}
}
resultados = append(resultados, r)
}
return resultados, nil
}
func gmailGetJSON(url, accessToken string, out interface{}) error {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := umindOAuthHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("no se pudo contactar Gmail: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("Gmail respondió %d: %s", resp.StatusCode, string(detalle))
}
return json.NewDecoder(resp.Body).Decode(out)
}
+109
View File
@@ -0,0 +1,109 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// EnviarCorreoMicrosoft manda un correo de texto plano vía Microsoft Graph
// (POST /me/sendMail) en nombre de la cuenta conectada.
func EnviarCorreoMicrosoft(conexion *models.UmindConexion, destinatario, asunto, cuerpo string) error {
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
if err != nil {
return fmt.Errorf("no se pudo descifrar el access token: %w", err)
}
payload := map[string]interface{}{
"message": map[string]interface{}{
"subject": asunto,
"body": map[string]string{"contentType": "Text", "content": cuerpo},
"toRecipients": []map[string]interface{}{
{"emailAddress": map[string]string{"address": destinatario}},
},
},
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, "https://graph.microsoft.com/v1.0/me/sendMail", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := umindOAuthHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("no se pudo contactar Microsoft Graph: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("Microsoft Graph respondió %d: %s", resp.StatusCode, string(detalle))
}
return nil
}
// LeerBandejaMicrosoft busca mensajes en la bandeja vía Microsoft Graph
// ($search sobre asunto/cuerpo/remitente) y devuelve un resumen liviano.
func LeerBandejaMicrosoft(conexion *models.UmindConexion, consulta string, limite int) ([]CorreoResumen, error) {
if limite <= 0 || limite > 10 {
limite = 10
}
accessToken, err := DescifrarSecretoUmind(conexion.AccessTokenEnc)
if err != nil {
return nil, fmt.Errorf("no se pudo descifrar el access token: %w", err)
}
q := fmt.Sprintf("https://graph.microsoft.com/v1.0/me/messages?$search=%s&$top=%d&$select=from,subject,receivedDateTime,bodyPreview",
url.QueryEscape(`"`+consulta+`"`), limite)
req, err := http.NewRequest(http.MethodGet, q, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
// $search requiere este header ("eventual consistency") en Microsoft Graph.
req.Header.Set("ConsistencyLevel", "eventual")
resp, err := umindOAuthHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("no se pudo contactar Microsoft Graph: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
detalle, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("Microsoft Graph respondió %d: %s", resp.StatusCode, string(detalle))
}
var out struct {
Value []struct {
From struct {
EmailAddress struct {
Name string `json:"name"`
Address string `json:"address"`
} `json:"emailAddress"`
} `json:"from"`
Subject string `json:"subject"`
ReceivedDateTime string `json:"receivedDateTime"`
BodyPreview string `json:"bodyPreview"`
} `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
resultados := make([]CorreoResumen, 0, len(out.Value))
for _, m := range out.Value {
resultados = append(resultados, CorreoResumen{
De: m.From.EmailAddress.Address,
Asunto: m.Subject,
Fecha: m.ReceivedDateTime,
Extracto: m.BodyPreview,
})
}
return resultados, nil
}
+266
View File
@@ -0,0 +1,266 @@
package services
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/microsoft"
)
const (
UmindOAuthGoogle = "google"
UmindOAuthMicrosoft = "microsoft"
)
// CorreoResumen es el formato común en el que enviar_correo/leer_bandeja
// devuelven un mensaje al agente, sin importar el proveedor real detrás.
type CorreoResumen struct {
De string `json:"de"`
Asunto string `json:"asunto"`
Fecha string `json:"fecha"`
Extracto string `json:"extracto"`
}
var umindOAuthHTTPClient = &http.Client{Timeout: 15 * time.Second}
// firmarState / verificarState arman el parámetro state del flujo OAuth
// autoverificable (tenantID + nonce + HMAC con APP_KEY) — evita necesitar una
// tabla de "estados pendientes": si la firma es válida, el state no fue
// alterado desde que lo generamos nosotros.
func firmarState(tenantID uint) (string, error) {
nonce := make([]byte, 8)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
payload := fmt.Sprintf("%d.%s", tenantID, hex.EncodeToString(nonce))
mac := hmac.New(sha256.New, []byte(app.Http.Server.Key))
mac.Write([]byte(payload))
return payload + "." + hex.EncodeToString(mac.Sum(nil)), nil
}
func verificarState(state string) (uint, error) {
partes := strings.Split(state, ".")
if len(partes) != 3 {
return 0, fmt.Errorf("formato de state inválido")
}
payload := partes[0] + "." + partes[1]
mac := hmac.New(sha256.New, []byte(app.Http.Server.Key))
mac.Write([]byte(payload))
esperada := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(esperada), []byte(partes[2])) {
return 0, fmt.Errorf("firma de state inválida")
}
tenantID, err := strconv.ParseUint(partes[0], 10, 64)
if err != nil {
return 0, fmt.Errorf("tenant_id inválido en state: %w", err)
}
return uint(tenantID), nil
}
func redirectURLOAuth(proveedor string) string {
return strings.TrimRight(app.Http.Server.Url, "/") + "/app/umind/conexiones/callback/" + proveedor
}
func oauth2ConfigPara(proveedor string) (*oauth2.Config, error) {
switch proveedor {
case UmindOAuthGoogle:
if app.Http.OAuth.GoogleClientID == "" || app.Http.OAuth.GoogleClientSecret == "" {
return nil, fmt.Errorf("Google OAuth no está configurado en el servidor (GOOGLE_OAUTH_CLIENT_ID/GOOGLE_OAUTH_CLIENT_SECRET)")
}
return &oauth2.Config{
ClientID: app.Http.OAuth.GoogleClientID,
ClientSecret: app.Http.OAuth.GoogleClientSecret,
RedirectURL: redirectURLOAuth(proveedor),
Scopes: []string{
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/userinfo.email",
},
Endpoint: google.Endpoint,
}, nil
case UmindOAuthMicrosoft:
if app.Http.OAuth.MSClientID == "" || app.Http.OAuth.MSClientSecret == "" {
return nil, fmt.Errorf("Microsoft OAuth no está configurado en el servidor (MS_OAUTH_CLIENT_ID/MS_OAUTH_CLIENT_SECRET)")
}
return &oauth2.Config{
ClientID: app.Http.OAuth.MSClientID,
ClientSecret: app.Http.OAuth.MSClientSecret,
RedirectURL: redirectURLOAuth(proveedor),
Scopes: []string{
"offline_access", "openid", "email",
"https://graph.microsoft.com/Mail.Send",
"https://graph.microsoft.com/Mail.Read",
},
Endpoint: microsoft.AzureADEndpoint("common"),
}, nil
default:
return nil, fmt.Errorf("proveedor desconocido: %s", proveedor)
}
}
// IniciarConexionOAuth arma la URL de autorización a la que hay que
// redirigir al staff. prompt=consent en Google fuerza a que siempre vuelva
// un refresh_token (si no, Google solo lo manda la primera vez que el
// usuario autoriza la app, nunca más).
func IniciarConexionOAuth(proveedor string, tenantID uint) (string, error) {
cfg, err := oauth2ConfigPara(proveedor)
if err != nil {
return "", err
}
state, err := firmarState(tenantID)
if err != nil {
return "", err
}
opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}
if proveedor == UmindOAuthGoogle {
opts = append(opts, oauth2.SetAuthURLParam("prompt", "consent"))
}
return cfg.AuthCodeURL(state, opts...), nil
}
// CompletarConexionOAuth intercambia el code por tokens, identifica la
// cuenta conectada y guarda la conexión cifrada. Reemplaza cualquier
// conexión previa activa del tenant (una cuenta de correo a la vez).
func CompletarConexionOAuth(proveedor, code, state string) (*models.UmindConexion, error) {
tenantID, err := verificarState(state)
if err != nil {
return nil, fmt.Errorf("state inválido: %w", err)
}
cfg, err := oauth2ConfigPara(proveedor)
if err != nil {
return nil, err
}
tok, err := cfg.Exchange(context.Background(), code)
if err != nil {
return nil, fmt.Errorf("no se pudo intercambiar el código de autorización: %w", err)
}
if tok.RefreshToken == "" {
return nil, fmt.Errorf("el proveedor no devolvió un refresh_token — revocá el acceso de esta app en tu cuenta y volvé a conectar")
}
email, err := obtenerEmailDeCuenta(proveedor, tok.AccessToken)
if err != nil {
log.Printf("[UMIND_OAUTH] no se pudo obtener el email de la cuenta conectada (%s): %v", proveedor, err)
}
accessEnc, err := CifrarSecretoUmind(tok.AccessToken)
if err != nil {
return nil, err
}
refreshEnc, err := CifrarSecretoUmind(tok.RefreshToken)
if err != nil {
return nil, err
}
if err := models.DesactivarConexionesDelTenant(tenantID); err != nil {
log.Printf("[UMIND_OAUTH] no se pudieron desactivar conexiones previas del tenant %d: %v", tenantID, err)
}
conexion := &models.UmindConexion{
TenantID: tenantID,
Proveedor: proveedor,
Email: email,
AccessTokenEnc: accessEnc,
RefreshTokenEnc: refreshEnc,
ExpiraEn: tok.Expiry,
Scopes: strings.Join(cfg.Scopes, " "),
Activo: true,
}
if err := models.CreateUmindConexion(conexion); err != nil {
return nil, err
}
return conexion, nil
}
func obtenerEmailDeCuenta(proveedor, accessToken string) (string, error) {
var url string
switch proveedor {
case UmindOAuthGoogle:
url = "https://www.googleapis.com/oauth2/v2/userinfo"
case UmindOAuthMicrosoft:
url = "https://graph.microsoft.com/v1.0/me"
default:
return "", fmt.Errorf("proveedor desconocido: %s", proveedor)
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := umindOAuthHTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
Email string `json:"email"` // Google
Mail string `json:"mail"` // Microsoft
UserPrincipalName string `json:"userPrincipalName"` // Microsoft, fallback si "mail" viene vacío
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
if out.Email != "" {
return out.Email, nil
}
if out.Mail != "" {
return out.Mail, nil
}
return out.UserPrincipalName, nil
}
// RefrescarSiVence renueva el access token si está vencido o a menos de 2
// minutos de vencer, y persiste el nuevo valor cifrado. Se llama justo antes
// de usar la conexión (enviar/leer correo), no por un cron aparte — ver nota
// de alcance en el plan: si en la práctica hace falta refresco proactivo, se
// agrega por pkg/services/cron_service.go sin tocar esta función.
func RefrescarSiVence(conexion *models.UmindConexion) error {
if time.Now().Add(2 * time.Minute).Before(conexion.ExpiraEn) {
return nil
}
cfg, err := oauth2ConfigPara(conexion.Proveedor)
if err != nil {
return err
}
refreshToken, err := DescifrarSecretoUmind(conexion.RefreshTokenEnc)
if err != nil {
return fmt.Errorf("no se pudo descifrar el refresh_token: %w", err)
}
nuevo, err := cfg.TokenSource(context.Background(), &oauth2.Token{RefreshToken: refreshToken}).Token()
if err != nil {
return fmt.Errorf("no se pudo refrescar el token: %w", err)
}
accessEnc, err := CifrarSecretoUmind(nuevo.AccessToken)
if err != nil {
return err
}
updates := map[string]interface{}{"access_token_enc": accessEnc, "expira_en": nuevo.Expiry}
if nuevo.RefreshToken != "" && nuevo.RefreshToken != refreshToken {
if refreshEnc, err := CifrarSecretoUmind(nuevo.RefreshToken); err == nil {
updates["refresh_token_enc"] = refreshEnc
conexion.RefreshTokenEnc = refreshEnc
}
}
if err := models.UpdateUmindConexion(conexion.ID, updates); err != nil {
log.Printf("[UMIND_OAUTH] no se pudo persistir el refresh del token (conexión %d): %v", conexion.ID, err)
}
conexion.AccessTokenEnc = accessEnc
conexion.ExpiraEn = nuevo.Expiry
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package services
import (
"testing"
"github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/config"
)
func TestFirmarYVerificarState(t *testing.T) {
app.Http = &config.AppConfig{Server: config.ServerConfig{Key: "clave-de-prueba-no-real"}}
state, err := firmarState(42)
if err != nil {
t.Fatalf("firmarState: %v", err)
}
tenantID, err := verificarState(state)
if err != nil {
t.Fatalf("verificarState de un state válido falló: %v", err)
}
if tenantID != 42 {
t.Errorf("tenantID = %d, esperaba 42", tenantID)
}
if _, err := verificarState(state + "x"); err == nil {
t.Error("un state alterado fue aceptado")
}
if _, err := verificarState("formato.invalido"); err == nil {
t.Error("un state con formato inválido fue aceptado")
}
if _, err := verificarState("noesnumero.aabbcc.deadbeef"); err == nil {
t.Error("un tenant_id no numérico fue aceptado")
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uMind — Orquestador</title>
<script type="module" crossorigin src="/orchestrator/assets/index-DopCJrBT.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DniozBCm.css">
<script type="module" crossorigin src="/orchestrator/assets/index-D6shNZ1Q.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-N2c9c8FD.css">
</head>
<body class="bg-gray-50">
<div id="app"></div>
@@ -0,0 +1,84 @@
package controllers
import (
"fmt"
"log"
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// GetUmindConexionesHandler lista las cuentas de correo conectadas de un
// tenant, sin exponer los tokens (ni cifrados ni en claro).
func GetUmindConexionesHandler(c *fiber.Ctx) error {
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err != nil || tenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
items, err := models.GetUmindConexionesByTenant(uint(tenantID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]fiber.Map, len(items))
for i, cx := range items {
out[i] = fiber.Map{
"ID": cx.ID, "tenant_id": cx.TenantID, "proveedor": cx.Proveedor,
"email": cx.Email, "activo": cx.Activo, "expira_en": cx.ExpiraEn,
}
}
return c.JSON(fiber.Map{"items": out})
}
// UmindConectarHandler redirige al staff a la pantalla de consentimiento de
// Google/Microsoft. Ruta: GET /app/umind/conexiones/conectar?tenant_id=&proveedor=
func UmindConectarHandler(c *fiber.Ctx) error {
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err != nil || tenantID == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
}
proveedor := c.Query("proveedor")
if _, err := models.GetUmindTenantByID(uint(tenantID)); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"})
}
url, err := services.IniciarConexionOAuth(proveedor, uint(tenantID))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
return c.Redirect(url, fiber.StatusFound)
}
// UmindOAuthCallbackHandler recibe la vuelta de Google/Microsoft, intercambia
// el code y redirige al staff de vuelta a la SPA.
// Ruta: GET /app/umind/conexiones/callback/:proveedor
func UmindOAuthCallbackHandler(c *fiber.Ctx) error {
proveedor := c.Params("proveedor")
if errParam := c.Query("error"); errParam != "" {
return c.Redirect(fmt.Sprintf("/orchestrator/?oauth_error=%s", errParam), fiber.StatusFound)
}
code := c.Query("code")
state := c.Query("state")
if code == "" || state == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "callback inválido"})
}
conexion, err := services.CompletarConexionOAuth(proveedor, code, state)
if err != nil {
log.Printf("[UMIND_OAUTH] error completando conexión (%s): %v", proveedor, err)
return c.Redirect("/orchestrator/?oauth_error=1", fiber.StatusFound)
}
return c.Redirect(fmt.Sprintf("/orchestrator/tenants/%d?tab=conexiones", conexion.TenantID), fiber.StatusFound)
}
func DeleteUmindConexionHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := models.DeleteUmindConexion(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
+4
View File
@@ -374,6 +374,10 @@ func UserRoutes(app fiber.Router) {
protected.Put("/umind/canales/:id", middlewares.SoloAdmin, controllers.UpdateUmindCanalHandler)
protected.Delete("/umind/canales/:id", middlewares.SoloAdmin, controllers.DeleteUmindCanalHandler)
protected.Post("/umind/chat", controllers.UmindChatPruebaHandler)
protected.Get("/umind/conexiones", controllers.GetUmindConexionesHandler)
protected.Get("/umind/conexiones/conectar", middlewares.SoloAdmin, controllers.UmindConectarHandler)
protected.Get("/umind/conexiones/callback/:proveedor", controllers.UmindOAuthCallbackHandler)
protected.Delete("/umind/conexiones/:id", middlewares.SoloAdmin, controllers.DeleteUmindConexionHandler)
// ─── uMind Orquestador (SPA Vue) ────────────────────────────────────────────
// Estáticos reales (JS/CSS del build) ya los sirve el Static("/") general