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>
669 lines
26 KiB
Vue
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">← 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>
|