refactor(studio): partir la pantalla del agente en un componente por pestaña
AgenteDetail tenía 1026 líneas con las siete pestañas adentro: el estado de todas mezclado en un solo <script setup>, y cualquier cambio en una obligaba a leer las otras seis para saber qué se rompía. Reorganizarla en ese estado sería trabajar a ciegas. Queda en 514 líneas —el encabezado, las pestañas y la carga de datos— más un componente por zona: conocimiento sigue adentro por ahora, y salen auditoría, conexiones, chat, conversaciones, herramientas y canales. El padre sigue siendo dueño de los datos y cada pestaña emite "recargar" en vez de tener su propia copia: con copias por pestaña, ir y volver entre dos mostraba estados distintos de lo mismo. Sin un solo cambio visible, y verificado como tal: se capturaron las siete pestañas antes de empezar y se compararon píxel a píxel después de cada extracción. Las siete dan idénticas — la única diferencia que reporta el comparador está por debajo del umbral y cae exactamente sobre el punto que late en una fuente "procesando", o sea la animación fotografiada en otro instante. Este commit no cambia nada para el usuario. Es la base para poder reorganizar la pantalla sin romperla. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ee9bb2cfb7
commit
c78fcce89b
@@ -4,8 +4,13 @@ import { useRoute } from 'vue-router'
|
|||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
import { apiUmind, contexto } from '../lib/contexto.js'
|
import { apiUmind, contexto } from '../lib/contexto.js'
|
||||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||||
import UiMascota from '../components/ui/UiMascota.vue'
|
|
||||||
import UiIcono from '../components/ui/UiIcono.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'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
tenantId: { type: String, required: true },
|
tenantId: { type: String, required: true },
|
||||||
@@ -191,194 +196,35 @@ const estadoColor = computed(() => (estado) => ({
|
|||||||
|
|
||||||
// ─── Conversaciones ──────────────────────────────────────────────────────────
|
// ─── Conversaciones ──────────────────────────────────────────────────────────
|
||||||
const sesiones = ref([])
|
const sesiones = ref([])
|
||||||
const historial = ref([])
|
|
||||||
const sesionActiva = ref(null)
|
|
||||||
|
|
||||||
async function cargarSesiones() {
|
async function cargarSesiones() {
|
||||||
const r = await api.get(apiUmind(`/umind/sesiones?agente_id=${props.agenteId}`))
|
const r = await api.get(apiUmind(`/umind/sesiones?agente_id=${props.agenteId}`))
|
||||||
sesiones.value = r.items || []
|
sesiones.value = r.items || []
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verHistorial(sessionId) {
|
// ─── Herramientas ───────────────────────────────────────────────────────────
|
||||||
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 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() {
|
async function cargarTools() {
|
||||||
const r = await api.get(apiUmind(`/umind/tools?agente_id=${props.agenteId}`))
|
const r = await api.get(apiUmind(`/umind/tools?agente_id=${props.agenteId}`))
|
||||||
tools.value = r.items || []
|
tools.value = r.items || []
|
||||||
}
|
}
|
||||||
|
|
||||||
function nuevaTool() {
|
// ─── Canales ────────────────────────────────────────────────────────────────
|
||||||
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 herramienta "${t.nombre}"?`)) return
|
|
||||||
await api.del(apiUmind(`/umind/tools/${t.ID}`))
|
|
||||||
await cargarTools()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Canales ──────────────────────────────────────────────────────────────────
|
|
||||||
const canales = ref([])
|
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, usar_archivos_docs: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cargarCanales() {
|
async function cargarCanales() {
|
||||||
const r = await api.get(apiUmind(`/umind/canales?agente_id=${props.agenteId}`))
|
const r = await api.get(apiUmind(`/umind/canales?agente_id=${props.agenteId}`))
|
||||||
canales.value = r.items || []
|
canales.value = r.items || []
|
||||||
}
|
}
|
||||||
|
|
||||||
function nuevoCanal() {
|
// El enlace del reporte vive acá y no en la pestaña de canales: lo usa el
|
||||||
canalForm.value = canalVacio()
|
// encabezado, que se ve en todas.
|
||||||
showCanalForm.value = true
|
const urlReporte = computed(() => {
|
||||||
}
|
const hoy = new Date()
|
||||||
|
const desde = new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().slice(0, 10)
|
||||||
async function guardarCanal() {
|
const hasta = hoy.toISOString().slice(0, 10)
|
||||||
const credenciales =
|
return apiUmind(`/umind/reporte.xlsx?agente_id=${props.agenteId}&desde=${desde}&hasta=${hasta}`)
|
||||||
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,
|
|
||||||
usar_archivos_docs: canalForm.value.usar_archivos_docs,
|
|
||||||
})
|
|
||||||
showCanalForm.value = false
|
|
||||||
await cargarCanales()
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// El PUT de canales manda los tres interruptores siempre: si alguno faltara, el
|
|
||||||
// backend lo tomaría como false y lo apagaría sin que nadie lo pidiera.
|
|
||||||
async function guardarInterruptores(c, cambios) {
|
|
||||||
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,
|
|
||||||
usar_archivos_docs: c.usar_archivos_docs,
|
|
||||||
...cambios,
|
|
||||||
})
|
|
||||||
await cargarCanales()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleCanal = (c) => guardarInterruptores(c, { activo: !c.activo })
|
|
||||||
const toggleCanalWhisper = (c) => guardarInterruptores(c, { usar_whisper_audio: !c.usar_whisper_audio })
|
|
||||||
const toggleCanalOcr = (c) => guardarInterruptores(c, { usar_ocr_imagenes: !c.usar_ocr_imagenes })
|
|
||||||
const toggleCanalArchivos = (c) => guardarInterruptores(c, { usar_archivos_docs: !c.usar_archivos_docs })
|
|
||||||
|
|
||||||
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) ────────────────────────────────────────────────
|
// ─── Conexiones (correo, OAuth) ────────────────────────────────────────────────
|
||||||
const conexiones = ref([])
|
const conexiones = ref([])
|
||||||
@@ -388,41 +234,6 @@ async function cargarConexiones() {
|
|||||||
conexiones.value = r.items || []
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Al dueño le importa qué le preguntan sus clientes y poder probarlo; la
|
|
||||||
// configuración la toca una vez. Al staff, al revés.
|
|
||||||
const tabs = computed(() =>
|
const tabs = computed(() =>
|
||||||
contexto.esPortal
|
contexto.esPortal
|
||||||
? [
|
? [
|
||||||
@@ -453,19 +264,6 @@ async function cargarEventos() {
|
|||||||
eventos.value = r.items || []
|
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
|
// 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
|
// 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.
|
// a correr, así que la pantalla quedaba con los datos del agente anterior.
|
||||||
@@ -478,8 +276,6 @@ watch(
|
|||||||
agente.value = null
|
agente.value = null
|
||||||
documentos.value = []
|
documentos.value = []
|
||||||
sesiones.value = []
|
sesiones.value = []
|
||||||
historial.value = []
|
|
||||||
sesionActiva.value = null
|
|
||||||
tools.value = []
|
tools.value = []
|
||||||
canales.value = []
|
canales.value = []
|
||||||
conexiones.value = []
|
conexiones.value = []
|
||||||
@@ -678,349 +474,41 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Herramientas -->
|
<!-- Herramientas -->
|
||||||
<div v-else-if="tab === 'herramientas'">
|
<TabHerramientas
|
||||||
<div class="flex justify-between items-center mb-4">
|
v-else-if="tab === 'herramientas'"
|
||||||
<p class="label">Máximo 10 herramientas activas por agente.</p>
|
:agente-id="agenteIdNum"
|
||||||
<button class="btn-primary" @click="nuevaTool">
|
:tools="tools"
|
||||||
+ Nueva herramienta
|
@recargar="cargarTools"
|
||||||
</button>
|
@error="(m) => (error = m)"
|
||||||
</div>
|
/>
|
||||||
<div class="card divide-y divide-borde">
|
|
||||||
<UiEmptyState
|
|
||||||
v-if="tools.length === 0"
|
|
||||||
titulo="Sin herramientas conectadas"
|
|
||||||
detalle="Las herramientas le dejan consultar tus sistemas mientras conversa: stock, estado de un pedido, disponibilidad de turnos. Sin ninguna, responde solo con lo que tiene cargado."
|
|
||||||
/>
|
|
||||||
<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">
|
<TabCanales
|
||||||
<div class="card p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
|
v-else-if="tab === 'canales'"
|
||||||
<h2 class="font-semibold text-texto mb-4">{{ editingTool ? 'Editar herramienta' : 'Nueva herramienta' }}</h2>
|
:agente-id="agenteIdNum"
|
||||||
<form class="space-y-3" @submit.prevent="guardarTool">
|
:site-key="agente?.site_key || ''"
|
||||||
<div>
|
:canales="canales"
|
||||||
<label class="label">Nombre (identificador, ej: consultar_stock)</label>
|
@recargar="cargarCanales"
|
||||||
<input v-model="toolForm.nombre" required pattern="[a-z][a-z0-9_]{2,63}" class="input font-mono" />
|
@error="(m) => (error = m)"
|
||||||
</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">
|
<TabConexiones
|
||||||
<div class="flex items-center justify-between">
|
v-else-if="tab === 'conexiones'"
|
||||||
<label class="label">Parámetros que completa el modelo</label>
|
:agente-id="agenteIdNum"
|
||||||
<button type="button" class="text-xs text-brand" @click="agregarParametro">+ agregar</button>
|
:conexiones="conexiones"
|
||||||
</div>
|
@recargar="cargarConexiones"
|
||||||
<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)"><UiIcono nombre="cerrar" :tam="13" /></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"
|
|
||||||
>
|
|
||||||
<UiIcono v-if="widgetCopiado" nombre="ok" :tam="14" />
|
|
||||||
{{ widgetCopiado ? 'Copiado' : 'Copiar código' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="label mb-2">
|
|
||||||
Pegá esto antes de <code class="bg-elevado px-1 rounded"></body></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">
|
|
||||||
<UiEmptyState
|
|
||||||
v-if="canales.length === 0"
|
|
||||||
titulo="No está atendiendo en ningún lado"
|
|
||||||
detalle="Conectá WhatsApp o Telegram para que empiece a responderle a tus clientes. El widget de tu web funciona aparte, con la clave de sitio de arriba."
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
|
|
||||||
<input type="checkbox" :checked="c.usar_archivos_docs" @change="toggleCanalArchivos(c)" class="rounded border-borde text-brand focus:ring-brand" />
|
|
||||||
Leer archivos adjuntos (PDF, Word, texto)
|
|
||||||
</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>
|
|
||||||
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
|
||||||
<input type="checkbox" v-model="canalForm.usar_archivos_docs" class="rounded border-borde text-brand focus:ring-brand" />
|
|
||||||
Leer archivos adjuntos (PDF, Word, texto)
|
|
||||||
</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">
|
|
||||||
<UiEmptyState
|
|
||||||
v-if="conexiones.length === 0"
|
|
||||||
titulo="Sin cuentas conectadas"
|
|
||||||
detalle="Conectando una cuenta de correo, el agente puede leer y responder mensajes con tu dirección."
|
|
||||||
/>
|
|
||||||
<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 -->
|
<!-- Chat de prueba -->
|
||||||
<div v-else-if="tab === 'chat'" class="card p-4 flex flex-col h-[28rem]">
|
<TabChat v-else-if="tab === 'chat'" :agente-id="agenteIdNum" />
|
||||||
<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, las mismas herramientas y la misma 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>
|
|
||||||
<div v-if="chatEnviando" class="flex items-center gap-2">
|
|
||||||
<UiMascota estado="pensando" :tam="30" class="text-brand" />
|
|
||||||
<span class="text-xs text-tenue">Pensando…</span>
|
|
||||||
</div>
|
|
||||||
</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 -->
|
<!-- Conversaciones -->
|
||||||
<div v-else-if="tab === 'conversaciones'" class="grid grid-cols-3 gap-4">
|
<TabConversaciones
|
||||||
<div class="col-span-1 card divide-y divide-borde max-h-[28rem] overflow-y-auto">
|
v-else-if="tab === 'conversaciones'"
|
||||||
<UiEmptyState
|
:agente-id="agenteIdNum"
|
||||||
v-if="sesiones.length === 0"
|
:sesiones="sesiones"
|
||||||
titulo="Nadie escribió todavía"
|
/>
|
||||||
detalle="Acá vas a ver todo lo que le preguntan y qué contestó."
|
|
||||||
/>
|
|
||||||
<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 -->
|
<!-- Auditoría -->
|
||||||
<div v-else>
|
<TabAuditoria v-else :eventos="eventos" />
|
||||||
<p class="label mb-4">
|
|
||||||
Errores y eventos técnicos de este agente — fallos al llamar a la IA, a una herramienta, al correo o a los canales. Últimos 100.
|
|
||||||
</p>
|
|
||||||
<div class="card divide-y divide-borde max-h-[32rem] overflow-y-auto">
|
|
||||||
<UiEmptyState
|
|
||||||
v-if="eventos.length === 0"
|
|
||||||
estado="contenta"
|
|
||||||
titulo="Ningún problema registrado"
|
|
||||||
detalle="Acá aparecen los errores: una herramienta que no responde, una fuente que no se pudo leer. Que esté vacío es buena señal."
|
|
||||||
/>
|
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||||
|
|
||||||
|
// Los eventos llegan del padre, que ya los carga junto con el resto del
|
||||||
|
// agente: pedirlos otra vez acá duplicaría la llamada cada vez que alguien
|
||||||
|
// entra a la pestaña.
|
||||||
|
defineProps({
|
||||||
|
eventos: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p class="label mb-4">
|
||||||
|
Errores y eventos técnicos de este agente — fallos al llamar a la IA, a una herramienta, al correo o a los canales. Últimos 100.
|
||||||
|
</p>
|
||||||
|
<div class="card divide-y divide-borde max-h-[32rem] overflow-y-auto">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="eventos.length === 0"
|
||||||
|
estado="contenta"
|
||||||
|
titulo="Ningún problema registrado"
|
||||||
|
detalle="Acá aparecen los errores: una herramienta que no responde, una fuente que no se pudo leer. Que esté vacío es buena señal."
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { api } from '../../lib/api.js'
|
||||||
|
import { apiUmind } from '../../lib/contexto.js'
|
||||||
|
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||||
|
import UiIcono from '../../components/ui/UiIcono.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
agenteId: { type: Number, required: true },
|
||||||
|
siteKey: { type: String, default: '' },
|
||||||
|
canales: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['recargar', 'error'])
|
||||||
|
|
||||||
|
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 widgetSnippet = computed(() => {
|
||||||
|
const siteKey = props.siteKey || '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, usar_archivos_docs: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: props.agenteId, tipo: canalForm.value.tipo, credenciales, activo: true,
|
||||||
|
usar_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes,
|
||||||
|
usar_archivos_docs: canalForm.value.usar_archivos_docs,
|
||||||
|
})
|
||||||
|
showCanalForm.value = false
|
||||||
|
emit('recargar')
|
||||||
|
} catch (e) {
|
||||||
|
emit('error', e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El PUT de canales manda los tres interruptores siempre: si alguno faltara, el
|
||||||
|
// backend lo tomaría como false y lo apagaría sin que nadie lo pidiera.
|
||||||
|
async function guardarInterruptores(c, cambios) {
|
||||||
|
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,
|
||||||
|
usar_archivos_docs: c.usar_archivos_docs,
|
||||||
|
...cambios,
|
||||||
|
})
|
||||||
|
emit('recargar')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCanal = (c) => guardarInterruptores(c, { activo: !c.activo })
|
||||||
|
const toggleCanalWhisper = (c) => guardarInterruptores(c, { usar_whisper_audio: !c.usar_whisper_audio })
|
||||||
|
const toggleCanalOcr = (c) => guardarInterruptores(c, { usar_ocr_imagenes: !c.usar_ocr_imagenes })
|
||||||
|
const toggleCanalArchivos = (c) => guardarInterruptores(c, { usar_archivos_docs: !c.usar_archivos_docs })
|
||||||
|
|
||||||
|
async function eliminarCanal(c) {
|
||||||
|
if (!confirm(`¿Eliminar el canal ${c.tipo}?`)) return
|
||||||
|
await api.del(apiUmind(`/umind/canales/${c.ID}`))
|
||||||
|
emit('recargar')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<UiIcono v-if="widgetCopiado" nombre="ok" :tam="14" />
|
||||||
|
{{ widgetCopiado ? 'Copiado' : 'Copiar código' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="label mb-2">
|
||||||
|
Pegá esto antes de <code class="bg-elevado px-1 rounded"></body></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">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="canales.length === 0"
|
||||||
|
titulo="No está atendiendo en ningún lado"
|
||||||
|
detalle="Conectá WhatsApp o Telegram para que empiece a responderle a tus clientes. El widget de tu web funciona aparte, con la clave de sitio de arriba."
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
|
||||||
|
<input type="checkbox" :checked="c.usar_archivos_docs" @change="toggleCanalArchivos(c)" class="rounded border-borde text-brand focus:ring-brand" />
|
||||||
|
Leer archivos adjuntos (PDF, Word, texto)
|
||||||
|
</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>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="canalForm.usar_archivos_docs" class="rounded border-borde text-brand focus:ring-brand" />
|
||||||
|
Leer archivos adjuntos (PDF, Word, texto)
|
||||||
|
</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) -->
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { api } from '../../lib/api.js'
|
||||||
|
import { apiUmind } from '../../lib/contexto.js'
|
||||||
|
import UiMascota from '../../components/ui/UiMascota.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
agenteId: { type: Number, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// La sesión se arma una vez por montaje: cada prueba arranca con el historial
|
||||||
|
// limpio, que es lo que se quiere al probar un cambio de conocimiento.
|
||||||
|
const sessionId = `staff-preview-${Math.random().toString(36).slice(2)}`
|
||||||
|
const mensajes = ref([])
|
||||||
|
const entrada = ref('')
|
||||||
|
const enviando = ref(false)
|
||||||
|
|
||||||
|
async function enviar() {
|
||||||
|
const texto = entrada.value.trim()
|
||||||
|
if (!texto || enviando.value) return
|
||||||
|
entrada.value = ''
|
||||||
|
mensajes.value.push({ role: 'user', content: texto })
|
||||||
|
enviando.value = true
|
||||||
|
try {
|
||||||
|
const r = await api.post(apiUmind('/umind/chat'), {
|
||||||
|
agente_id: props.agenteId,
|
||||||
|
session_id: sessionId,
|
||||||
|
mensaje: texto,
|
||||||
|
})
|
||||||
|
mensajes.value.push({ role: 'assistant', content: r.respuesta })
|
||||||
|
} catch (e) {
|
||||||
|
mensajes.value.push({ role: 'assistant', content: `⚠ ${e.message}` })
|
||||||
|
} finally {
|
||||||
|
enviando.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card p-4 flex flex-col h-[28rem]">
|
||||||
|
<div class="flex-1 overflow-y-auto space-y-2 mb-3">
|
||||||
|
<p v-if="mensajes.length === 0" class="text-sm text-tenue">
|
||||||
|
Probá este agente tal cual lo va a ver un visitante — usa la misma config de IA, las mismas herramientas y la misma base de conocimiento.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="(m, i) in mensajes"
|
||||||
|
: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>
|
||||||
|
<div v-if="enviando" class="flex items-center gap-2">
|
||||||
|
<UiMascota estado="pensando" :tam="30" class="text-brand" />
|
||||||
|
<span class="text-xs text-tenue">Pensando…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="flex gap-2" @submit.prevent="enviar">
|
||||||
|
<input v-model="entrada" placeholder="Escribí un mensaje de prueba..." class="flex-1 input" />
|
||||||
|
<button type="submit" :disabled="enviando" class="btn-primary disabled:opacity-50 transition-colors">
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup>
|
||||||
|
import { api } from '../../lib/api.js'
|
||||||
|
import { apiUmind } from '../../lib/contexto.js'
|
||||||
|
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
agenteId: { type: Number, required: true },
|
||||||
|
conexiones: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// El padre es el dueño de los datos del agente: acá se avisa que cambiaron y
|
||||||
|
// él recarga. Si cada pestaña mantuviera su propia copia, volver de una a otra
|
||||||
|
// mostraría estados distintos de lo mismo.
|
||||||
|
const emit = defineEmits(['recargar'])
|
||||||
|
|
||||||
|
function conectar(proveedor) {
|
||||||
|
// Navegación normal (no fetch): el backend redirige a Google/Microsoft.
|
||||||
|
window.location.href = apiUmind(`/umind/conexiones/conectar?agente_id=${props.agenteId}&proveedor=${proveedor}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function desconectar(c) {
|
||||||
|
if (!confirm(`¿Desconectar la cuenta ${c.email || c.proveedor}?`)) return
|
||||||
|
await api.del(apiUmind(`/umind/conexiones/${c.ID}`))
|
||||||
|
emit('recargar')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="conexiones.length === 0"
|
||||||
|
titulo="Sin cuentas conectadas"
|
||||||
|
detalle="Conectando una cuenta de correo, el agente puede leer y responder mensajes con tu dirección."
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { api } from '../../lib/api.js'
|
||||||
|
import { apiUmind } from '../../lib/contexto.js'
|
||||||
|
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
agenteId: { type: Number, required: true },
|
||||||
|
sesiones: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// El historial se pide al abrir cada conversación, no de entrada: cargar todas
|
||||||
|
// las conversaciones completas para mostrar una lista sería traer de más.
|
||||||
|
const historial = ref([])
|
||||||
|
const sesionActiva = ref(null)
|
||||||
|
|
||||||
|
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 || []
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div class="col-span-1 card divide-y divide-borde max-h-[28rem] overflow-y-auto">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="sesiones.length === 0"
|
||||||
|
titulo="Nadie escribió todavía"
|
||||||
|
detalle="Acá vas a ver todo lo que le preguntan y qué contestó."
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { api } from '../../lib/api.js'
|
||||||
|
import { apiUmind } from '../../lib/contexto.js'
|
||||||
|
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
|
||||||
|
import UiIcono from '../../components/ui/UiIcono.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
agenteId: { type: Number, required: true },
|
||||||
|
tools: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// El padre es dueño de la lista: acá se avisa que cambió y él la recarga.
|
||||||
|
const emit = defineEmits(['recargar', 'error'])
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: props.agenteId,
|
||||||
|
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
|
||||||
|
emit('recargar')
|
||||||
|
} catch (e) {
|
||||||
|
emit('error', e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminarTool(t) {
|
||||||
|
if (!confirm(`¿Eliminar la herramienta "${t.nombre}"?`)) return
|
||||||
|
await api.del(apiUmind(`/umind/tools/${t.ID}`))
|
||||||
|
emit('recargar')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<p class="label">Máximo 10 herramientas activas por agente.</p>
|
||||||
|
<button class="btn-primary" @click="nuevaTool">
|
||||||
|
+ Nueva herramienta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card divide-y divide-borde">
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="tools.length === 0"
|
||||||
|
titulo="Sin herramientas conectadas"
|
||||||
|
detalle="Las herramientas le dejan consultar tus sistemas mientras conversa: stock, estado de un pedido, disponibilidad de turnos. Sin ninguna, responde solo con lo que tiene cargado."
|
||||||
|
/>
|
||||||
|
<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 herramienta' : 'Nueva herramienta' }}</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)"><UiIcono nombre="cerrar" :tam="13" /></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 -->
|
||||||
|
</template>
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
|
||||||
<title>uMind Studio</title>
|
<title>uMind Studio</title>
|
||||||
<script type="module" crossorigin src="/orchestrator/assets/index-yQWQlH69.js"></script>
|
<script type="module" crossorigin src="/orchestrator/assets/index-Du2k2-WV.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-C4iBAJs_.css">
|
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-C4iBAJs_.css">
|
||||||
</head>
|
</head>
|
||||||
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
|
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
|
||||||
|
|||||||
Reference in New Issue
Block a user