Files
soft_usite/orchestrator/src/views/AgenteDetail.vue
T
Lizandro GuarnizoandClaude Opus 5 eddec45857 feat(umind): el correo entra al agente por dos puertas distintas — y son distintas a propósito
En "Dónde atiende", el canal correo: el agente lee una casilla y responde solo
los correos que llegan, con su base de conocimiento, igual que atiende WhatsApp.
Como el correo no tiene webhook, se revisa por intervalo — y el intervalo lo
elige el cliente por canal (2 a 60 minutos): una inmobiliaria quiere 2, a un
estudio contable con 30 le sobra. El cron corre cada minuto pero cada casilla
se revisa solo cuando le toca, con un pool de 8 para que 100 casillas no salgan
a la red en el mismo instante.

Las guardas que separan "asistente" de "incidente", cada una con su test:
nunca responde correo automático ni se responde a sí mismo (el bucle con otro
autoresponder); la revisión se marca ANTES de conectar, así una contraseña
cambiada no martilla el login cada minuto hasta que el host del cliente nos
bloquea; y el correo se marca leído recién cuando la respuesta salió — si el
envío falla, queda sin leer y se reintenta.

En "Lo que sabe", las cuentas de correo: casillas que el agente consulta a
pedido — "revisame los correos de hoy y haceme un resumen" — sin nada de fondo.
Solo lectura en serio: Peek, INBOX en read-only, y un test que falla si alguien
le agrega un marcado. Ahora se pueden conectar varias por agente; con más de
una, el modelo pregunta cuál en vez de adivinar — resumirle a alguien la
casilla que no pidió no es un error menor. El alta pide correo y contraseña:
el host se deduce (mail.<dominio>) y el campo técnico aparece recién si eso
falla. Se prueba la conexión antes de guardar, con la persona mirando.

Enviar por una cuenta conectada está bloqueado a propósito: para responder
correos está el canal, con sus guardas. Una tool de envío sin límites es una
máquina de spam con el dominio del cliente.

La navegación acompaña: "Lo que sabe" agrupa Información, Cuentas de correo y
Herramientas — tres formas de saber, no tres pantallas sueltas — y Avanzado
queda solo con Problemas.

El SMTP se unificó en una sola implementación que comparten soporte y el canal:
el bug de STARTTLS que abría dos conexiones ya se pagó una vez.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:08:46 -05:00

669 lines
26 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'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
import UiIcono from '../components/ui/UiIcono.vue'
import TabAuditoria from './agente/TabAuditoria.vue'
import TabConexiones from './agente/TabConexiones.vue'
import TabChat from './agente/TabChat.vue'
import TabConversaciones from './agente/TabConversaciones.vue'
import TabHerramientas from './agente/TabHerramientas.vue'
import TabCanales from './agente/TabCanales.vue'
import EstadoAgente from './agente/EstadoAgente.vue'
import PrimerosPasos from './agente/PrimerosPasos.vue'
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
: contexto.esPortal
? 'conversaciones'
: '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 || []
}
// Tres formas de cargar conocimiento, no una: la web, un archivo (lista de
// precios, condiciones) y lo que el dueño escribe a mano, que es lo más
// valioso y lo único que no está en ningún documento.
const fuenteNueva = ref('texto')
const autoActualizar = ref(true)
const notaTitulo = ref('')
const notaTexto = ref('')
const archivoRef = ref(null)
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,
auto_actualizar: autoActualizar.value,
})
nuevaUrl.value = ''
await cargarDocumentos()
} catch (e) {
error.value = e.message
} finally {
ingestando.value = false
}
}
async function agregarNota() {
if (!notaTexto.value.trim()) return
ingestando.value = true
error.value = ''
try {
await api.post(apiUmind('/umind/documentos/texto'), {
agente_id: agenteIdNum.value,
titulo: notaTitulo.value.trim(),
contenido: notaTexto.value,
})
notaTitulo.value = ''
notaTexto.value = ''
await cargarDocumentos()
} catch (e) {
error.value = e.message
} finally {
ingestando.value = false
}
}
async function subirArchivo() {
const f = archivoRef.value?.files?.[0]
if (!f) return
ingestando.value = true
error.value = ''
try {
const fd = new FormData()
fd.append('agente_id', String(agenteIdNum.value))
fd.append('archivo', f)
// Sin Content-Type a mano: el navegador tiene que poner el boundary.
const res = await fetch(apiUmind('/umind/documentos/archivo'), { method: 'POST', body: fd })
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'No se pudo subir')
archivoRef.value.value = ''
await cargarDocumentos()
} catch (e) {
error.value = e.message
} finally {
ingestando.value = false
}
}
// Editar una fuente escrita a mano. Las plantillas de rubro dejan las notas
// con valores entre corchetes para reemplazar — sin esto, no había con qué.
// Ver qué se leyó de una fuente, fragmento por fragmento. Para un sitio
// crawleado es la única ventana: no tiene Editar (se rehace crawleando) y
// hasta ahora "9 fragmentos" era una caja negra.
const viendoDoc = ref(null)
const fragmentos = ref([])
const cargandoFragmentos = ref(false)
async function verFragmentos(d) {
viendoDoc.value = d
fragmentos.value = []
cargandoFragmentos.value = true
try {
const r = await api.get(apiUmind(`/umind/documentos/${d.ID}/fragmentos`))
fragmentos.value = r.items || []
} catch (e) {
error.value = e.message
viendoDoc.value = null
} finally {
cargandoFragmentos.value = false
}
}
const editandoDoc = ref(null)
const docForm = ref({ titulo: '', contenido: '' })
const guardandoDoc = ref(false)
function editarDocumento(d) {
editandoDoc.value = d
docForm.value = { titulo: d.origen, contenido: d.contenido || '' }
}
async function guardarDocumento() {
if (!editandoDoc.value) return
guardandoDoc.value = true
error.value = ''
try {
await api.put(apiUmind(`/umind/documentos/${editandoDoc.value.ID}`), {
titulo: docForm.value.titulo,
contenido: docForm.value.contenido,
})
editandoDoc.value = null
await cargarDocumentos()
} catch (e) {
error.value = e.message
} finally {
guardandoDoc.value = false
}
}
async function reprocesar(d) {
error.value = ''
try {
await api.post(apiUmind(`/umind/documentos/${d.ID}/reprocesar`), {})
await cargarDocumentos()
} catch (e) {
error.value = e.message
}
}
async function alternarAuto(d) {
try {
await api.put(apiUmind(`/umind/documentos/${d.ID}`), { auto_actualizar: !d.auto_actualizar })
await cargarDocumentos()
} catch (e) {
error.value = e.message
}
}
// "hace 3 días" en vez de una fecha: lo que importa no es cuándo se procesó
// sino qué tan viejo es lo que el agente está contestando.
function antiguedad(fecha) {
if (!fecha) return 'sin procesar'
const dias = Math.floor((Date.now() - new Date(fecha)) / 86400000)
if (dias <= 0) return 'hoy'
if (dias === 1) return 'ayer'
if (dias < 30) return `hace ${dias} días`
const meses = Math.floor(dias / 30)
return meses === 1 ? 'hace un mes' : `hace ${meses} meses`
}
function estaVieja(d) {
if (!d.procesado_at) return false
return Date.now() - new Date(d.procesado_at) > 60 * 86400000
}
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([])
async function cargarSesiones() {
const r = await api.get(apiUmind(`/umind/sesiones?agente_id=${props.agenteId}`))
sesiones.value = r.items || []
}
// ─── Herramientas ───────────────────────────────────────────────────────────
const tools = ref([])
async function cargarTools() {
const r = await api.get(apiUmind(`/umind/tools?agente_id=${props.agenteId}`))
tools.value = r.items || []
}
// ─── Canales ────────────────────────────────────────────────────────────────
const canales = ref([])
async function cargarCanales() {
const r = await api.get(apiUmind(`/umind/canales?agente_id=${props.agenteId}`))
canales.value = r.items || []
}
// El enlace del reporte vive acá y no en la pestaña de canales: lo usa el
// encabezado, que se ve en todas.
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}`)
})
// ─── 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 || []
}
// Tres zonas en vez de siete pestañas. Las siete eran nuestras siete tablas
// —documentos, tools, canales, conexiones, eventos— y un dueño de negocio no
// piensa "voy a Conexiones": piensa "¿por qué contestó mal?" o "quiero que
// sepa mis precios nuevos".
//
// Lo que se usa todos los días queda arriba; lo que se toca una vez y se
// olvida, en Avanzado.
const ZONAS = [
['conversaciones', 'Conversaciones'],
['conocimiento', 'Lo que sabe'],
['canales', 'Dónde atiende'],
]
// "Lo que sabe" agrupa todo lo que alimenta las respuestas: la información
// cargada, las casillas que puede consultar y las herramientas que puede
// llamar. Son tres formas de saber, no tres pantallas sueltas.
const SUBSECCIONES_SABE = [
['conocimiento', 'Información'],
['conexiones', 'Cuentas de correo'],
['herramientas', 'Herramientas'],
]
const enLoQueSabe = computed(() => SUBSECCIONES_SABE.some(([k]) => k === tab.value))
const AVANZADO = [['auditoria', 'Problemas']]
// La guía se muestra hasta que sabe algo Y atiende en algún lado. Probar es
// deseable pero no bloquea: nadie queda preso de la guía por no haber chateado.
const configurando = computed(
() => !documentos.value.some((d) => d.estado === 'listo') || !canales.value.some((c) => c.activo),
)
const mostrarAvanzado = ref(false)
// Si se entra directo a una pestaña avanzada (por la URL o desde el estado de
// arriba), la sección tiene que verse abierta o el botón activo queda
// escondido detrás de un "Avanzado" cerrado.
if (AVANZADO.some(([k]) => k === tab.value)) mostrarAvanzado.value = true
function irA(destino) {
tab.value = destino
if (AVANZADO.some(([k]) => k === destino)) mostrarAvanzado.value = true
}
// ─── Auditoría ────────────────────────────────────────────────────────────────
const eventos = ref([])
async function cargarEventos() {
const r = await api.get(apiUmind(`/umind/eventos?agente_id=${props.agenteId}`))
eventos.value = r.items || []
}
// 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 = []
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-4 mt-1 flex items-start justify-between gap-4">
<h1 class="text-xl font-semibold text-texto min-w-0 truncate">{{ agente.nombre }}</h1>
<!-- 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">
<UiIcono nombre="descargar" :tam="15" /> Reporte del mes
</a>
</div>
<!-- Mientras el agente esté a medio configurar, la guía de puesta en
marcha; después, el estado de todos los días. Nunca los dos: dos
resúmenes a la vez es no tener ninguno. -->
<PrimerosPasos
v-if="agente && configurando"
:documentos="documentos"
:canales="canales"
:sesiones="sesiones"
@ir="irA"
/>
<EstadoAgente
v-else-if="agente"
:canales="canales"
:documentos="documentos"
:sesiones="sesiones"
:eventos="eventos"
@ir="irA"
/>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<div class="flex items-center gap-1.5 mb-6 flex-wrap">
<button
v-for="[key, label] in ZONAS"
:key="key"
class="px-3.5 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors"
:class="(key === 'conocimiento' ? enLoQueSabe : 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>
<!-- Lo que se configura una vez no puede competir todos los días con lo
que se mira todos los días. -->
<button
class="ml-auto text-xs text-tenue hover:text-texto inline-flex items-center gap-1"
@click="mostrarAvanzado = !mostrarAvanzado"
>
Avanzado
<span class="text-[10px]">{{ mostrarAvanzado ? '▲' : '▼' }}</span>
</button>
</div>
<div v-if="enLoQueSabe" class="flex gap-1.5 mb-6 flex-wrap -mt-3">
<button
v-for="[key, label] in SUBSECCIONES_SABE"
:key="key"
class="px-3 py-1 rounded-full text-xs whitespace-nowrap transition-colors"
:class="tab === key
? 'bg-brand/15 text-brand font-medium border border-brand/40'
: 'text-tenue border border-transparent hover:text-texto'"
@click="tab = key"
>
{{ label }}
</button>
</div>
<div v-if="mostrarAvanzado" class="flex gap-1.5 mb-6 flex-wrap">
<button
v-for="[key, label] in AVANZADO"
:key="key"
class="px-3 py-1 rounded-full text-xs whitespace-nowrap transition-colors"
:class="tab === key
? 'bg-texto text-white dark:bg-gray-200 dark:text-gray-900 font-medium'
: 'bg-white dark:bg-gray-900 text-tenue border border-borde hover:border-brand/50'"
@click="tab = key"
>
{{ label }}
</button>
</div>
<!-- Lo que sabe: el conocimiento y la prueba, lado a lado.
El bucle real es leer lo que sabe probar corregir probar de
nuevo. Con las dos cosas en pestañas separadas, eso eran seis clics
por corrección; acá es escribir y ver. -->
<div v-if="tab === 'conocimiento'" class="grid gap-4 lg:grid-cols-[1fr_21rem] items-start">
<div>
<div class="card p-4 mb-4">
<div class="flex gap-1 mb-3">
<button
v-for="[k, ico, l] in [['texto', 'escribir', 'Escribir'], ['archivo', 'archivo', 'Subir archivo'], ['url', 'sitio', 'Sitio web']]"
:key="k"
class="px-3 py-1.5 rounded-lg text-sm inline-flex items-center gap-1.5"
:class="fuenteNueva === k ? 'bg-brand text-white font-medium' : 'text-tenue hover:text-texto'"
@click="fuenteNueva = k"
><UiIcono :nombre="ico" :tam="15" /> {{ l }}</button>
</div>
<!-- Lo que el dueño sabe y no está escrito en ningún lado. -->
<form v-if="fuenteNueva === 'texto'" class="space-y-2" @submit.prevent="agregarNota">
<input v-model="notaTitulo" placeholder="Título (ej: Horarios y zonas de entrega)" class="input" />
<textarea
v-model="notaTexto"
rows="5"
required
placeholder="Atendemos de lunes a viernes de 9 a 18. No hacemos envíos fuera de la ciudad. La garantía es de 6 meses y cubre…"
class="input font-normal"
></textarea>
<div class="flex items-center justify-between">
<p class="text-xs text-tenue">Lo que te preguntan todos los días y no está en tu web.</p>
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
{{ ingestando ? 'Guardando…' : 'Guardar' }}
</button>
</div>
</form>
<form v-else-if="fuenteNueva === 'archivo'" class="space-y-2" @submit.prevent="subirArchivo">
<input ref="archivoRef" type="file" accept=".pdf,.docx,.txt,.md,.csv,.html,image/*" class="text-sm text-texto" />
<div class="flex items-center justify-between">
<p class="text-xs text-tenue">PDF, Word, texto o una foto. Ej: tu lista de precios.</p>
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
{{ ingestando ? 'Leyendo…' : 'Subir' }}
</button>
</div>
</form>
<form v-else class="space-y-2" @submit.prevent="agregarFuente">
<div class="flex gap-2">
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="input flex-1" />
<input v-model="maxPaginas" type="number" min="1" max="200" class="input w-24" title="Máximo de páginas a leer" />
</div>
<div class="flex items-center justify-between">
<label class="flex items-center gap-2 text-xs text-tenue cursor-pointer">
<input v-model="autoActualizar" type="checkbox" class="rounded border-borde text-brand focus:ring-brand" />
Releer el sitio cada semana
</label>
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50">
{{ ingestando ? 'Leyendo…' : 'Leer sitio' }}
</button>
</div>
</form>
</div>
<div class="card divide-y divide-borde">
<UiEmptyState
v-if="documentos.length === 0"
estado="buscando"
titulo="Todavía no sabe nada de tu negocio"
detalle="Sin información cargada va a contestar que no sabe. Empezá por escribir tus horarios y lo que te preguntan todos los días — es lo más rápido y lo que más se nota."
/>
<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 inline-flex items-center gap-1" :class="estadoColor(d.estado)">
<span v-if="d.estado === 'procesando'" class="latido"></span>
{{ d.estado }}
</span>
<span v-if="d.tipo"> · {{ { url: 'sitio', archivo: 'archivo', texto: 'nota' }[d.tipo] || d.tipo }}</span>
<span v-if="d.total_chunks"> · {{ d.total_chunks }} fragmentos</span>
<!-- La antigüedad, en rojo cuando pasó de dos meses: es lo único
que delata que el agente contesta con información vieja. -->
<span v-if="d.procesado_at" :class="estaVieja(d) ? 'text-amber-600 dark:text-amber-400 font-medium' : ''">
· leído {{ antiguedad(d.procesado_at) }}
</span>
<span v-if="d.auto_actualizar"> · se actualiza sola</span>
<span v-if="d.error" class="text-red-600 dark:text-red-400"> · {{ d.error }}</span>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<button
v-if="d.tipo === 'url'"
class="text-xs text-tenue hover:text-texto"
:title="d.auto_actualizar ? 'Dejar de releer sola' : 'Releer el sitio cada semana'"
@click="alternarAuto(d)"
><UiIcono :nombre="d.auto_actualizar ? 'auto' : 'refrescar'" :tam="13" class="mr-1" />{{ d.auto_actualizar ? 'auto' : 'manual' }}</button>
<button
class="text-sm text-tenue hover:text-texto"
:disabled="!d.total_chunks"
:class="!d.total_chunks ? 'opacity-40 cursor-default' : ''"
title="Ver qué se leyó de esta fuente"
@click="verFragmentos(d)"
>Ver</button>
<!-- Solo lo que tiene texto propio guardado: una URL se rehace
crawleando, editarla a mano se perdería en la próxima pasada. -->
<button
v-if="d.tipo === 'texto' || d.tipo === 'archivo'"
class="text-sm text-brand hover:underline"
@click="editarDocumento(d)"
>Editar</button>
<button
class="text-sm text-tenue hover:text-texto disabled:opacity-40"
:disabled="d.estado === 'procesando'"
:title="d.tipo === 'url' ? 'Volver a leer el sitio' : 'Rehacer los fragmentos'"
@click="reprocesar(d)"
>Actualizar</button>
<button class="text-red-500 hover:text-red-700 text-sm" @click="eliminarDocumento(d.ID)">Eliminar</button>
</div>
</div>
</div>
</div>
<!-- La prueba, al lado y siempre visible mientras se edita el
conocimiento. En pantallas angostas cae abajo, que es el orden en
que igual se trabaja. -->
<div class="lg:sticky lg:top-4">
<p class="label mb-2">Probalo</p>
<TabChat :agente-id="agenteIdNum" />
</div>
<!-- Ver los fragmentos de una fuente, solo lectura -->
<div
v-if="viendoDoc"
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
@click.self="viendoDoc = null"
>
<div class="card w-full max-w-2xl p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<div class="flex items-baseline justify-between gap-3 mb-1">
<h2 class="font-semibold text-texto truncate">{{ viendoDoc.origen }}</h2>
<span class="text-xs text-tenue shrink-0 tabular-nums">
{{ fragmentos.length }} {{ fragmentos.length === 1 ? 'fragmento' : 'fragmentos' }}
</span>
</div>
<p class="text-xs text-tenue mb-4">
Esto es exactamente lo que el asistente puede consultar de esta fuente.
Si acá falta algo o se leyó mal, eso mismo le va a faltar en las respuestas.
</p>
<p v-if="cargandoFragmentos" class="text-sm text-tenue">Cargando</p>
<div v-else class="space-y-2">
<div
v-for="(f, i) in fragmentos"
:key="f.ID"
class="bg-elevado border border-borde rounded-lg p-3 text-sm text-texto whitespace-pre-wrap"
>
<span class="text-[10px] text-tenue tabular-nums block mb-1">#{{ i + 1 }}</span>{{ f.contenido }}
</div>
</div>
<div class="flex justify-end pt-4">
<button class="btn-ghost" @click="viendoDoc = null">Cerrar</button>
</div>
</div>
</div>
<!-- Editar una nota o el texto extraído de un archivo -->
<div
v-if="editandoDoc"
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
@click.self="editandoDoc = null"
>
<div class="card w-full max-w-2xl p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<h2 class="font-semibold text-texto mb-1">Editar fuente</h2>
<p class="text-xs text-tenue mb-4">
Al guardar se vuelve a leer y el agente empieza a contestar con esto.
</p>
<form class="space-y-3" @submit.prevent="guardarDocumento">
<div>
<label class="label">Título</label>
<input v-model="docForm.titulo" required class="input" />
</div>
<div>
<label class="label">Contenido</label>
<textarea
v-model="docForm.contenido"
rows="16"
required
class="input font-normal leading-relaxed"
></textarea>
<p class="text-xs text-tenue mt-1.5">
Si viene de una plantilla, reemplazá lo que está [entre corchetes] por tus datos.
Lo que quede sin reemplazar el agente lo va a leer tal cual.
</p>
</div>
<div class="flex justify-end gap-2 pt-1">
<button type="button" class="btn-ghost" @click="editandoDoc = null">Cancelar</button>
<button type="submit" class="btn-primary" :disabled="guardandoDoc">
{{ guardandoDoc ? 'Guardando…' : 'Guardar y volver a leer' }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Herramientas -->
<TabHerramientas
v-else-if="tab === 'herramientas'"
:agente-id="agenteIdNum"
:tools="tools"
@recargar="cargarTools"
@error="(m) => (error = m)"
/>
<TabCanales
v-else-if="tab === 'canales'"
:agente-id="agenteIdNum"
:site-key="agente?.site_key || ''"
:canales="canales"
@recargar="cargarCanales"
@error="(m) => (error = m)"
/>
<TabConexiones
v-else-if="tab === 'conexiones'"
:agente-id="agenteIdNum"
:conexiones="conexiones"
@recargar="cargarConexiones"
/>
<!-- Chat de prueba -->
<TabChat v-else-if="tab === 'chat'" :agente-id="agenteIdNum" />
<!-- Conversaciones -->
<TabConversaciones
v-else-if="tab === 'conversaciones'"
:agente-id="agenteIdNum"
:sesiones="sesiones"
/>
<!-- Auditoría -->
<TabAuditoria v-else :eventos="eventos" />
</div>
</template>