Files
soft_usite/orchestrator/src/views/AgenteDetail.vue
T
Lizandro GuarnizoandClaude Sonnet 5 ad3458bb71 fix(studio): cambiar de tenant o de agente no recargaba la pantalla
Al navegar de /tenants/2 a /tenants/3, vue-router reusa la misma instancia
del componente porque es la misma ruta con otro parámetro. onMounted no
vuelve a dispararse, así que la vista seguía mostrando los agentes del
tenant anterior.

Las tres vistas tenían el mismo problema: TenantAgentes, AgenteDetail y
Uso. Ahora reaccionan al parámetro (watch con immediate) en vez de al
montaje.

En AgenteDetail se limpia el estado antes de pedir los datos nuevos: si no,
durante la carga se ven los documentos, canales y conversaciones del agente
anterior bajo el nombre del nuevo, que es peor que una pantalla vacía.

Verificado navegando de verdad entre dos tenants con agentes distintos: la
lista pasa de "Ventas T2" a "Soporte T3 / Cobros T3".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 08:01:37 -05:00

734 lines
31 KiB
Vue

<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js'
const props = defineProps({
tenantId: { type: String, required: true },
agenteId: { type: String, required: true },
})
const agenteIdNum = computed(() => Number(props.agenteId))
const route = useRoute()
const agente = ref(null)
const error = ref('')
const tab = ref(typeof route.query.tab === 'string' ? route.query.tab : 'conocimiento')
// ─── Base de conocimiento ───────────────────────────────────────────────────
const documentos = ref([])
const nuevaUrl = ref('')
const maxPaginas = ref(30)
const ingestando = ref(false)
async function cargarAgente() {
const r = await api.get(apiUmind(`/umind/agentes?tenant_id=${props.tenantId}`))
agente.value = (r.items || []).find((x) => String(x.ID) === props.agenteId) || null
}
async function cargarDocumentos() {
const r = await api.get(apiUmind(`/umind/documentos?agente_id=${props.agenteId}`))
documentos.value = r.items || []
}
async function agregarFuente() {
if (!nuevaUrl.value.trim()) return
ingestando.value = true
error.value = ''
try {
await api.post(apiUmind('/umind/documentos'), {
agente_id: agenteIdNum.value,
url: nuevaUrl.value.trim(),
max_paginas: Number(maxPaginas.value) || 30,
})
nuevaUrl.value = ''
await cargarDocumentos()
} catch (e) {
error.value = e.message
} finally {
ingestando.value = false
}
}
async function eliminarDocumento(id) {
if (!confirm('¿Eliminar esta fuente y sus fragmentos indexados?')) return
await api.del(apiUmind(`/umind/documentos/${id}`))
await cargarDocumentos()
}
const estadoColor = computed(() => (estado) => ({
listo: 'badge-ok',
procesando: 'badge-alerta',
pendiente: 'badge-neutro',
error: 'badge-error',
}[estado] || 'badge-neutro'))
// ─── Conversaciones ──────────────────────────────────────────────────────────
const sesiones = ref([])
const historial = ref([])
const sesionActiva = ref(null)
async function cargarSesiones() {
const r = await api.get(apiUmind(`/umind/sesiones?agente_id=${props.agenteId}`))
sesiones.value = r.items || []
}
async function verHistorial(sessionId) {
sesionActiva.value = sessionId
const r = await api.get(apiUmind(`/umind/historial?agente_id=${props.agenteId}&session_id=${sessionId}`))
historial.value = r.items || []
}
// ─── Tools custom (webhooks) ─────────────────────────────────────────────────
const tools = ref([])
const showToolForm = ref(false)
const editingTool = ref(null)
const toolForm = ref(toolVacio())
function toolVacio() {
return {
nombre: '', descripcion: '', url: '', auth_header_nombre: '', auth_header_valor: '',
tocarAuth: false, parametros: [], activa: true,
}
}
async function cargarTools() {
const r = await api.get(apiUmind(`/umind/tools?agente_id=${props.agenteId}`))
tools.value = r.items || []
}
function nuevaTool() {
editingTool.value = null
toolForm.value = toolVacio()
showToolForm.value = true
}
function editarTool(t) {
editingTool.value = t
let parametros = []
try {
parametros = JSON.parse(t.parametros_json || '[]') || []
} catch {
parametros = []
}
toolForm.value = {
nombre: t.nombre, descripcion: t.descripcion, url: t.url,
auth_header_nombre: t.auth_header_nombre, auth_header_valor: '', tocarAuth: false,
parametros, activa: t.activa,
}
showToolForm.value = true
}
function agregarParametro() {
toolForm.value.parametros.push({ nombre: '', tipo: 'string', descripcion: '', requerido: false })
}
function quitarParametro(i) {
toolForm.value.parametros.splice(i, 1)
}
async function guardarTool() {
const payload = {
agente_id: agenteIdNum.value,
nombre: toolForm.value.nombre.trim(),
descripcion: toolForm.value.descripcion,
url: toolForm.value.url.trim(),
auth_header_nombre: toolForm.value.auth_header_nombre,
parametros: toolForm.value.parametros,
activa: toolForm.value.activa,
}
if (toolForm.value.tocarAuth) {
payload.auth_header_valor = toolForm.value.auth_header_valor
}
try {
if (editingTool.value) {
await api.put(apiUmind(`/umind/tools/${editingTool.value.ID}`), payload)
} else {
await api.post(apiUmind('/umind/tools'), payload)
}
showToolForm.value = false
await cargarTools()
} catch (e) {
error.value = e.message
}
}
async function eliminarTool(t) {
if (!confirm(`¿Eliminar la tool "${t.nombre}"?`)) return
await api.del(apiUmind(`/umind/tools/${t.ID}`))
await cargarTools()
}
// ─── Canales ──────────────────────────────────────────────────────────────────
const canales = ref([])
const showCanalForm = ref(false)
const canalForm = ref(canalVacio())
const widgetCopiado = ref(false)
// El reporte se descarga con una navegación normal (no fetch): así el
// navegador maneja el archivo y la cookie de sesión viaja sola.
const urlReporte = computed(() => {
const hoy = new Date()
const desde = new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().slice(0, 10)
const hasta = hoy.toISOString().slice(0, 10)
return apiUmind(`/umind/reporte.xlsx?agente_id=${props.agenteId}&desde=${desde}&hasta=${hasta}`)
})
const widgetSnippet = computed(() => {
const siteKey = agente.value?.site_key || 'TU_SITE_KEY'
return `<script src="${window.location.origin}/widget/umind.js" data-site="${siteKey}" defer><\/script>`
})
async function copiarWidget() {
try {
await navigator.clipboard.writeText(widgetSnippet.value)
widgetCopiado.value = true
setTimeout(() => (widgetCopiado.value = false), 2000)
} catch {
error.value = 'No se pudo copiar automáticamente — seleccioná el texto y copialo a mano.'
}
}
function canalVacio() {
return {
tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '',
usar_whisper_audio: false, usar_ocr_imagenes: false,
}
}
async function cargarCanales() {
const r = await api.get(apiUmind(`/umind/canales?agente_id=${props.agenteId}`))
canales.value = r.items || []
}
function nuevoCanal() {
canalForm.value = canalVacio()
showCanalForm.value = true
}
async function guardarCanal() {
const credenciales =
canalForm.value.tipo === 'telegram'
? { bot_token: canalForm.value.bot_token }
: {
phone_number_id: canalForm.value.phone_number_id,
access_token: canalForm.value.access_token,
app_secret: canalForm.value.app_secret,
verify_token: canalForm.value.verify_token,
}
try {
await api.post(apiUmind('/umind/canales'), {
agente_id: agenteIdNum.value, tipo: canalForm.value.tipo, credenciales, activo: true,
usar_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes,
})
showCanalForm.value = false
await cargarCanales()
} catch (e) {
error.value = e.message
}
}
async function toggleCanal(c) {
await api.put(apiUmind(`/umind/canales/${c.ID}`), {
activo: !c.activo, credenciales: {},
usar_whisper_audio: c.usar_whisper_audio, usar_ocr_imagenes: c.usar_ocr_imagenes,
})
await cargarCanales()
}
async function toggleCanalWhisper(c) {
await api.put(apiUmind(`/umind/canales/${c.ID}`), {
activo: c.activo, credenciales: {},
usar_whisper_audio: !c.usar_whisper_audio, usar_ocr_imagenes: c.usar_ocr_imagenes,
})
await cargarCanales()
}
async function toggleCanalOcr(c) {
await api.put(apiUmind(`/umind/canales/${c.ID}`), {
activo: c.activo, credenciales: {},
usar_whisper_audio: c.usar_whisper_audio, usar_ocr_imagenes: !c.usar_ocr_imagenes,
})
await cargarCanales()
}
async function eliminarCanal(c) {
if (!confirm(`¿Eliminar el canal ${c.tipo}?`)) return
await api.del(apiUmind(`/umind/canales/${c.ID}`))
await cargarCanales()
}
// ─── Conexiones (correo, OAuth) ────────────────────────────────────────────────
const conexiones = ref([])
async function cargarConexiones() {
const r = await api.get(apiUmind(`/umind/conexiones?agente_id=${props.agenteId}`))
conexiones.value = r.items || []
}
function conectar(proveedor) {
// Navegación normal (no fetch): el backend redirige a Google/Microsoft.
window.location.href = apiUmind(`/umind/conexiones/conectar?agente_id=${agenteIdNum.value}&proveedor=${proveedor}`)
}
async function desconectar(c) {
if (!confirm(`¿Desconectar la cuenta ${c.email || c.proveedor}?`)) return
await api.del(apiUmind(`/umind/conexiones/${c.ID}`))
await cargarConexiones()
}
// ─── Chat de prueba ───────────────────────────────────────────────────────────
const chatSessionId = `staff-preview-${Math.random().toString(36).slice(2)}`
const chatMensajes = ref([])
const chatInput = ref('')
const chatEnviando = ref(false)
async function enviarChatPrueba() {
const texto = chatInput.value.trim()
if (!texto || chatEnviando.value) return
chatInput.value = ''
chatMensajes.value.push({ role: 'user', content: texto })
chatEnviando.value = true
try {
const r = await api.post(apiUmind('/umind/chat'), { agente_id: agenteIdNum.value, session_id: chatSessionId, mensaje: texto })
chatMensajes.value.push({ role: 'assistant', content: r.respuesta })
} catch (e) {
chatMensajes.value.push({ role: 'assistant', content: `⚠️ ${e.message}` })
} finally {
chatEnviando.value = false
}
}
const tabs = [
['conocimiento', 'Base de conocimiento'],
['herramientas', 'Herramientas'],
['canales', 'Canales'],
['conexiones', 'Conexiones'],
['chat', 'Chat de prueba'],
['conversaciones', 'Conversaciones'],
['auditoria', 'Auditoría'],
]
// ─── Auditoría ────────────────────────────────────────────────────────────────
const eventos = ref([])
async function cargarEventos() {
const r = await api.get(apiUmind(`/umind/eventos?agente_id=${props.agenteId}`))
eventos.value = r.items || []
}
const nivelColor = computed(() => (nivel) => ({
error: 'badge-error',
warn: 'badge-alerta',
}[nivel] || 'badge-neutro'))
function formatearFecha(iso) {
try {
return new Date(iso).toLocaleString()
} catch {
return iso
}
}
// Se observan los dos parámetros: al saltar de un agente a otro (o a un
// agente de otro tenant) vue-router reusa la instancia y onMounted no vuelve
// a correr, así que la pantalla quedaba con los datos del agente anterior.
watch(
() => [props.tenantId, props.agenteId],
async () => {
error.value = ''
// Se limpia antes de pedir: si no, durante la carga se ven los datos del
// agente anterior bajo el nombre del nuevo.
agente.value = null
documentos.value = []
sesiones.value = []
historial.value = []
sesionActiva.value = null
tools.value = []
canales.value = []
conexiones.value = []
eventos.value = []
try {
await Promise.all([cargarAgente(), cargarDocumentos(), cargarSesiones(), cargarTools(), cargarCanales(), cargarConexiones(), cargarEventos()])
} catch (e) {
error.value = e.message
}
},
{ immediate: true },
)
</script>
<template>
<div>
<router-link :to="`/tenants/${tenantId}`" class="label hover:text-brand">&larr; Agentes</router-link>
<div v-if="agente" class="mb-6 mt-1 flex items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="text-xl font-semibold text-texto">{{ agente.nombre }}</h1>
<p class="text-xs text-tenue mt-1">
site_key: <code class="bg-elevado px-1.5 py-0.5 rounded">{{ agente.site_key }}</code>
</p>
</div>
<!-- Un archivo que se reenvía sirve para justificar el gasto puertas
adentro; un panel al que hay que entrar, no. -->
<a :href="urlReporte" class="btn-ghost shrink-0" title="Conversaciones y consumo del mes en Excel">
Reporte del mes
</a>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<div class="flex gap-1.5 mb-6 overflow-x-auto pb-1">
<button
v-for="[key, label] in tabs"
:key="key"
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-borde hover:border-brand/50'"
@click="tab = key"
>
{{ label }}
</button>
</div>
<!-- Base de conocimiento -->
<div v-if="tab === 'conocimiento'">
<form class="flex gap-2 mb-4" @submit.prevent="agregarFuente">
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="flex-1 border border-borde bg-white dark:bg-gray-900 text-texto rounded-lg px-3 py-2 text-sm" />
<input v-model="maxPaginas" type="number" min="1" max="200" class="w-24 border border-borde bg-white dark:bg-gray-900 text-texto rounded-lg px-3 py-2 text-sm" title="Máximo de páginas a crawlear" />
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50 transition-colors">
{{ ingestando ? 'Agregando...' : 'Crawlear sitio' }}
</button>
</form>
<div class="card divide-y divide-borde">
<div v-if="documentos.length === 0" class="p-6 text-sm text-tenue">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-texto">{{ d.origen }}</div>
<div class="text-xs text-tenue 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 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>
</div>
</div>
</div>
<!-- Herramientas -->
<div v-else-if="tab === 'herramientas'">
<div class="flex justify-between items-center mb-4">
<p class="label">Máximo 10 tools activas por agente.</p>
<button class="btn-primary" @click="nuevaTool">
+ Nueva tool
</button>
</div>
<div class="card divide-y divide-borde">
<div v-if="tools.length === 0" class="p-6 text-sm text-tenue">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-texto font-mono">{{ t.nombre }}</div>
<div class="label mt-0.5">{{ t.descripcion }}</div>
<div class="text-xs text-tenue mt-0.5">
{{ t.url }}
<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-tenue 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/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
<div class="card p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
<h2 class="font-semibold text-texto mb-4">{{ editingTool ? 'Editar tool' : 'Nueva tool' }}</h2>
<form class="space-y-3" @submit.prevent="guardarTool">
<div>
<label class="label">Nombre (identificador, ej: consultar_stock)</label>
<input v-model="toolForm.nombre" required pattern="[a-z][a-z0-9_]{2,63}" class="input font-mono" />
</div>
<div>
<label class="label">Descripción (esto lo lee el modelo para decidir cuándo usarla)</label>
<textarea v-model="toolForm.descripcion" rows="2" required class="input"></textarea>
</div>
<div>
<label class="label">URL del webhook (https)</label>
<input v-model="toolForm.url" type="url" required placeholder="https://..." class="input" />
</div>
<div class="border border-borde rounded-lg p-3 space-y-2">
<div class="flex items-center justify-between">
<label class="label">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-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs font-mono" />
<select v-model="p.tipo" class="border border-borde bg-white dark:bg-gray-800 text-texto 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-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs" />
<label class="label flex items-center gap-1">
<input v-model="p.requerido" type="checkbox" /> req.
</label>
<button type="button" class="text-red-400 text-xs" @click="quitarParametro(i)"></button>
</div>
<p v-if="toolForm.parametros.length === 0" class="text-xs text-gray-400">Sin parámetros.</p>
</div>
<div class="border border-borde rounded-lg p-3 space-y-2">
<label class="label">Autenticación saliente (opcional)</label>
<input v-model="toolForm.auth_header_nombre" placeholder="Nombre del header, ej: Authorization" class="input" />
<label class="flex items-center gap-2 label">
<input v-model="toolForm.tocarAuth" type="checkbox" />
{{ editingTool ? 'Cambiar el valor del secreto' : 'Configurar valor' }}
</label>
<input
v-if="toolForm.tocarAuth"
v-model="toolForm.auth_header_valor"
type="password"
placeholder="Valor del header (ej: Bearer xxxx)"
class="input"
/>
</div>
<label class="flex items-center gap-2 text-sm text-texto">
<input v-model="toolForm.activa" type="checkbox" /> Activa
</label>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="btn-ghost" @click="showToolForm = false">Cancelar</button>
<button type="submit" class="btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<!-- Canales -->
<div v-else-if="tab === 'canales'">
<div class="card p-4 mb-4">
<div class="flex items-center justify-between mb-2">
<div>
<span class="font-medium text-texto">Web (widget)</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs badge-ok">
siempre activo
</span>
</div>
<button
class="text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
:class="widgetCopiado ? 'bg-green-600 text-white' : 'bg-brand hover:bg-brand-dark text-white'"
@click="copiarWidget"
>
{{ widgetCopiado ? '✓ Copiado' : 'Copiar código' }}
</button>
</div>
<p class="label mb-2">
Pegá esto antes de <code class="bg-elevado px-1 rounded">&lt;/body&gt;</code> en las páginas de tu sitio.
</p>
<pre class="bg-elevado border border-borde rounded-lg p-2.5 text-xs text-texto overflow-x-auto"><code>{{ widgetSnippet }}</code></pre>
</div>
<div class="flex justify-end mb-4">
<button class="btn-primary" @click="nuevoCanal">
+ Nuevo canal
</button>
</div>
<div class="card divide-y divide-borde">
<div v-if="canales.length === 0" class="p-6 text-sm text-tenue">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-texto capitalize">{{ c.tipo }}</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
{{ c.activo ? 'activo' : 'inactivo' }}
</span>
</div>
<div class="flex gap-3 text-sm">
<button class="text-tenue 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>
<div class="flex gap-4 mt-2 text-xs">
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
<input type="checkbox" :checked="c.usar_whisper_audio" @change="toggleCanalWhisper(c)" class="rounded border-borde text-brand focus:ring-brand" />
Transcribir audios (Whisper)
</label>
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
<input type="checkbox" :checked="c.usar_ocr_imagenes" @change="toggleCanalOcr(c)" class="rounded border-borde text-brand focus:ring-brand" />
Leer texto de imágenes (OCR)
</label>
</div>
<p class="label mt-1 break-all">
Webhook: <code class="bg-elevado px-1 rounded">{{ c.webhook_url }}</code>
</p>
<p v-if="c.tipo === 'whatsapp'" class="text-xs text-tenue 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 dark:text-red-400 mt-1">{{ c.ultimo_error }}</p>
</div>
</div>
<div v-if="showCanalForm" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showCanalForm = false">
<div class="card p-6 w-full max-w-md">
<h2 class="font-semibold text-texto mb-4">Nuevo canal</h2>
<form class="space-y-3" @submit.prevent="guardarCanal">
<div>
<label class="label">Tipo</label>
<select v-model="canalForm.tipo" class="input">
<option value="telegram">Telegram</option>
<option value="whatsapp">WhatsApp Business</option>
</select>
</div>
<template v-if="canalForm.tipo === 'telegram'">
<div>
<label class="label">Bot token (de @BotFather)</label>
<input v-model="canalForm.bot_token" type="password" required class="input" />
</div>
</template>
<template v-else>
<div>
<label class="label">Phone Number ID</label>
<input v-model="canalForm.phone_number_id" required class="input" />
</div>
<div>
<label class="label">Access Token</label>
<input v-model="canalForm.access_token" type="password" required class="input" />
</div>
<div>
<label class="label">App Secret</label>
<input v-model="canalForm.app_secret" type="password" required class="input" />
</div>
<div>
<label class="label">Verify Token (lo inventás vos, lo vas a usar en Meta)</label>
<input v-model="canalForm.verify_token" required class="input" />
</div>
</template>
<div class="flex flex-col gap-2 pt-1">
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_whisper_audio" class="rounded border-borde text-brand focus:ring-brand" />
Transcribir audios con Whisper
</label>
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_ocr_imagenes" class="rounded border-borde text-brand focus:ring-brand" />
Leer texto de imágenes con OCR
</label>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="btn-ghost" @click="showCanalForm = false">Cancelar</button>
<button type="submit" class="btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<!-- Conexiones (correo, OAuth) -->
<div v-else-if="tab === 'conexiones'">
<p class="label mb-4">
Conectá una cuenta de correo para que este agente pueda enviar y leer correo en su nombre.
Se soporta una cuenta activa a la vez.
</p>
<div class="flex gap-2 mb-4">
<button class="border border-borde 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-borde 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="card divide-y divide-borde">
<div v-if="conexiones.length === 0" class="p-6 text-sm text-tenue">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-texto capitalize">{{ c.proveedor }}</span>
<span class="ml-2 text-sm text-tenue">{{ c.email }}</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
{{ 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="card 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-tenue">
Probá este agente tal cual lo va a ver un visitante usa la misma config de IA y las mismas tools/base de conocimiento.
</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-elevado text-texto'"
>
{{ m.content }}
</div>
<p v-if="chatEnviando" class="text-xs text-tenue">Pensando...</p>
</div>
<form class="flex gap-2" @submit.prevent="enviarChatPrueba">
<input v-model="chatInput" placeholder="Escribí un mensaje de prueba..." class="flex-1 input" />
<button type="submit" :disabled="chatEnviando" class="btn-primary disabled:opacity-50 transition-colors">
Enviar
</button>
</form>
</div>
<!-- Conversaciones -->
<div v-else-if="tab === 'conversaciones'" class="grid grid-cols-3 gap-4">
<div class="col-span-1 card divide-y divide-borde max-h-[28rem] overflow-y-auto">
<div v-if="sesiones.length === 0" class="p-4 text-sm text-tenue">Sin conversaciones.</div>
<button
v-for="s in sesiones"
:key="s.session_id"
class="w-full text-left p-3 hover:bg-elevado text-sm"
:class="sesionActiva === s.session_id ? 'bg-gray-50 dark:bg-gray-800' : ''"
@click="verHistorial(s.session_id)"
>
<div class="text-texto truncate">{{ s.content }}</div>
<div class="text-xs text-tenue mt-0.5">{{ s.session_id }}</div>
</button>
</div>
<div class="col-span-2 card p-4 max-h-[28rem] overflow-y-auto space-y-2">
<p v-if="!sesionActiva" class="text-sm text-tenue">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-elevado text-texto'"
>
{{ m.content }}
</div>
</div>
</div>
<!-- Auditoría -->
<div v-else>
<p class="label mb-4">
Errores y eventos técnicos de este agente fallos al llamar al AI, a tools, a correo o a los canales. Últimos 100.
</p>
<div class="card divide-y divide-borde max-h-[32rem] overflow-y-auto">
<div v-if="eventos.length === 0" class="p-6 text-sm text-tenue">Sin eventos registrados buena señal.</div>
<details v-for="e in eventos" :key="e.ID" class="p-3">
<summary class="cursor-pointer flex items-center gap-2 text-sm">
<span class="px-1.5 py-0.5 rounded text-xs shrink-0" :class="nivelColor(e.nivel)">{{ e.nivel }}</span>
<span class="text-tenue text-xs shrink-0">{{ e.origen }}</span>
<span class="text-texto truncate">{{ e.mensaje }}</span>
<span class="text-tenue text-xs ml-auto shrink-0">{{ formatearFecha(e.CreatedAt) }}</span>
</summary>
<pre v-if="e.detalle" class="mt-2 bg-elevado border border-borde rounded-lg p-2 text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap">{{ e.detalle }}</pre>
</details>
</div>
</div>
</div>
</template>