feat(studio): rediseño con design tokens, pantalla de consumo y rename a Studio

El SPA pasa a llamarse uMind Studio y vive en /studio (el nombre
"Orquestador" no le decía nada al cliente final, que ahora es quien lo usa).

Diseño:
- Los colores viven una sola vez como variables CSS + clases semánticas
  (.card, .input, .btn-*, .badge-*). Antes cada elemento repetía el par
  claro/oscuro a mano en cientos de lugares y cambiar un tono era buscar
  y reemplazar.
- darkMode pasa de 'media' a 'class' con toggle propio persistido: el
  usuario elige, no el sistema operativo. Se aplica antes de montar la
  app para que no parpadee.
- Componentes compartidos (UiModal, UiBadge, UiEmptyState) donde antes
  había markup duplicado inline.

Nueva pantalla de consumo (/tenants/:id/uso): filtros por fecha con
atajos, total del período, pendiente de facturar, barra contra el tope
del plan, desglose por tipo y gráfico por día. El gráfico son divs con
altura porcentual — no vale traer una librería de charts para esto.

Rename:
- /studio y /portal/studio; /orchestrator redirige 301 conservando la
  ruta interna, así los enlaces guardados siguen funcionando. Los assets
  del bundle quedan exentos (el `base` de Vite sigue en /orchestrator/):
  redirigirlos rompería el SPA.
- El submódulo sembrado migra el mismo registro buscando por las tres
  URLs históricas (/app/umind → /orchestrator → /studio) en vez de
  dejar entradas duplicadas en el menú.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 12:02:54 -05:00
co-authored by Claude Sonnet 5
parent 023a8af494
commit d8f9acd1f8
26 changed files with 685 additions and 224 deletions
+13 -3
View File
@@ -1,12 +1,22 @@
<script setup>
import Sidebar from './components/Sidebar.vue'
import { tema, alternarTema } from './lib/tema.js'
</script>
<template>
<div class="min-h-screen flex bg-gray-50 dark:bg-gray-950">
<div class="min-h-screen flex">
<Sidebar />
<main class="flex-1 min-w-0">
<div class="max-w-4xl mx-auto px-8 py-10">
<main class="flex-1 min-w-0 flex flex-col">
<header class="h-14 shrink-0 flex items-center justify-end gap-1 px-6 border-b border-borde">
<button
class="btn-ghost !px-2.5 !py-1.5"
:title="tema === 'oscuro' ? 'Cambiar a claro' : 'Cambiar a oscuro'"
@click="alternarTema"
>
<span class="text-base leading-none">{{ tema === 'oscuro' ? '☀️' : '🌙' }}</span>
</button>
</header>
<div class="flex-1 max-w-5xl w-full mx-auto px-8 py-8 animate-aparecer">
<router-view />
</div>
</main>
+28 -28
View File
@@ -111,16 +111,16 @@ onMounted(() => {
</script>
<template>
<aside class="w-64 shrink-0 h-screen sticky top-0 flex flex-col border-r border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
<div class="px-4 py-4 border-b border-gray-200 dark:border-gray-800">
<router-link to="/" class="text-base font-semibold text-gray-800 dark:text-gray-100">
<aside class="w-64 shrink-0 h-screen sticky top-0 flex flex-col border-r border-borde bg-superficie">
<div class="h-14 px-4 flex items-center border-b border-borde">
<router-link to="/" class="text-base font-semibold text-texto">
uMind <span class="text-brand">Studio</span>
</router-link>
</div>
<div v-if="!contexto.esPortal" class="px-3 pt-3">
<button
class="w-full bg-brand hover:bg-brand-dark text-white text-sm font-medium px-3 py-2 rounded-lg transition-colors"
class="btn-primary w-full"
@click="nuevoTenant"
>
+ Nuevo tenant
@@ -130,37 +130,37 @@ onMounted(() => {
<p v-if="error" class="px-3 pt-2 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
<p v-if="loading" class="px-2 text-xs text-gray-400">Cargando...</p>
<p v-else-if="tenants.length === 0" class="px-2 text-xs text-gray-400">
<p v-if="loading" class="px-2 text-xs text-tenue">Cargando...</p>
<p v-else-if="tenants.length === 0" class="px-2 text-xs text-tenue">
{{ contexto.esPortal ? 'Todavía no tenés ningún espacio asignado. Escribinos y lo activamos.' : 'Sin tenants todavía.' }}
</p>
<div
v-for="t in tenants"
:key="t.ID"
class="group flex items-center rounded-lg transition-colors"
:class="tenantActivoId === String(t.ID) ? 'bg-brand/10 dark:bg-brand/20' : 'hover:bg-gray-100 dark:hover:bg-gray-800'"
:class="tenantActivoId === String(t.ID) ? 'bg-brand/10' : 'hover:bg-elevado'"
>
<router-link
:to="`/tenants/${t.ID}`"
class="flex-1 min-w-0 px-2.5 py-2 text-sm"
:class="tenantActivoId === String(t.ID) ? 'text-brand-dark dark:text-brand font-medium' : 'text-gray-700 dark:text-gray-300'"
:class="tenantActivoId === String(t.ID) ? 'text-brand font-medium' : 'text-texto'"
>
<div class="truncate">{{ t.nombre }}</div>
<div class="flex items-center gap-1 mt-0.5">
<span class="w-1.5 h-1.5 rounded-full" :class="t.activo ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'"></span>
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ t.activo ? 'activo' : 'inactivo' }}</span>
<span class="w-1.5 h-1.5 rounded-full" :class="t.activo ? 'bg-green-500' : 'bg-tenue/40'"></span>
<span class="text-[11px] text-tenue">{{ t.activo ? 'activo' : 'inactivo' }}</span>
</div>
</router-link>
<div v-if="!contexto.esPortal" class="flex opacity-0 group-hover:opacity-100 transition-opacity pr-1.5 gap-0.5">
<button
class="p-1 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
class="p-1 text-tenue hover:text-texto"
title="Editar"
@click="editarTenant(t)"
>
</button>
<button
class="p-1 text-gray-400 hover:text-red-600"
class="p-1 text-tenue hover:text-red-600"
title="Eliminar"
@click="eliminar(t)"
>
@@ -177,66 +177,66 @@ onMounted(() => {
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
@click.self="showForm = false"
>
<div class="bg-white dark:bg-gray-900 rounded-xl p-6 w-full max-w-lg border border-gray-200 dark:border-gray-800">
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">
<div class="card w-full max-w-lg p-6 animate-escalar shadow-2xl">
<h2 class="font-semibold text-texto mb-4">
{{ editing ? 'Editar tenant' : 'Nuevo tenant' }}
</h2>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
<p class="text-xs text-tenue mb-3">
Un tenant es el negocio/sitio dueño de los dominios permitidos. La config de IA, tono y demás se configuran por agente, dentro del tenant.
</p>
<form class="space-y-3" @submit.prevent="guardar">
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Nombre</label>
<label class="label">Nombre</label>
<input
v-model="form.nombre"
required
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
class="input"
/>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Dominios permitidos (separados por coma)</label>
<label class="label">Dominios permitidos (separados por coma)</label>
<input
v-model="form.dominios_permitidos"
placeholder="ejemplo.com, www.ejemplo.com"
required
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
class="input"
/>
</div>
<div v-if="clientes.length" class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Cliente</label>
<label class="label">Cliente</label>
<select
v-model="form.cliente_id"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
class="input"
>
<option :value="null"> sin asignar </option>
<option v-for="c in clientes" :key="c.ID" :value="c.ID">{{ c.nombre }}</option>
</select>
<p class="text-[11px] text-gray-400 mt-1">Define quién ve este tenant desde el portal.</p>
<p class="text-[11px] text-tenue mt-1">Define quién ve este tenant desde el portal.</p>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Plan</label>
<label class="label">Plan</label>
<select
v-model="form.plan_id"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
class="input"
>
<option :value="null"> sin plan </option>
<option v-for="p in planes" :key="p.ID" :value="p.ID">
{{ p.nombre }} ({{ p.max_agentes === 0 ? '' : p.max_agentes }} agentes)
</option>
</select>
<p class="text-[11px] text-gray-400 mt-1">Límite de agentes y precios de consumo.</p>
<p class="text-[11px] text-tenue mt-1">Límite de agentes y precios de consumo.</p>
</div>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<label class="flex items-center gap-2 text-sm text-texto">
<input v-model="form.activo" type="checkbox" />
Activo
</label>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400" @click="showForm = false">
<button type="button" class="btn-ghost" @click="showForm = false">
Cancelar
</button>
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">
<button type="submit" class="btn-primary">
Guardar
</button>
</div>
@@ -0,0 +1,16 @@
<script setup>
// tipo: ok | alerta | error | neutro
defineProps({ tipo: { type: String, default: 'neutro' }, punto: Boolean })
const clases = { ok: 'badge-ok', alerta: 'badge-alerta', error: 'badge-error', neutro: 'badge-neutro' }
</script>
<template>
<span :class="clases[tipo] || 'badge-neutro'">
<span
v-if="punto"
class="w-1.5 h-1.5 rounded-full"
:class="{ ok: 'bg-green-500', alerta: 'bg-amber-500', error: 'bg-red-500', neutro: 'bg-gray-400' }[tipo]"
></span>
<slot />
</span>
</template>
@@ -0,0 +1,12 @@
<script setup>
defineProps({ icono: { type: String, default: '' }, titulo: String, detalle: String })
</script>
<template>
<div class="flex flex-col items-center justify-center text-center py-12 px-6">
<div v-if="icono" class="text-3xl mb-3 opacity-70">{{ icono }}</div>
<p class="text-sm font-medium text-texto">{{ titulo }}</p>
<p v-if="detalle" class="text-xs text-tenue mt-1 max-w-sm">{{ detalle }}</p>
<div class="mt-4"><slot /></div>
</div>
</template>
@@ -0,0 +1,20 @@
<script setup>
defineProps({ titulo: { type: String, default: '' }, ancho: { type: String, default: 'max-w-lg' } })
const emit = defineEmits(['cerrar'])
</script>
<template>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
@click.self="emit('cerrar')"
@keydown.esc="emit('cerrar')"
>
<div :class="['card w-full p-6 max-h-[90vh] overflow-y-auto animate-escalar shadow-2xl', ancho]">
<div v-if="titulo" class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-texto">{{ titulo }}</h2>
<button class="btn-ghost !px-2 !py-1" type="button" @click="emit('cerrar')"></button>
</div>
<slot />
</div>
</div>
</template>
+1 -1
View File
@@ -7,7 +7,7 @@ const enPortal = window.location.pathname.startsWith('/portal/')
export const contexto = {
esPortal: enPortal,
// Base del router (vue-router en modo history).
baseRuta: enPortal ? '/portal/studio/' : '/orchestrator/',
baseRuta: enPortal ? '/portal/studio/' : '/studio/',
// Prefijo de la API de uMind.
apiBase: enPortal ? '/portal' : '/app',
// A dónde mandar al usuario cuando se le venció la sesión.
+23
View File
@@ -0,0 +1,23 @@
import { ref } from 'vue'
// El tema se aplica antes de montar la app para que no haya un parpadeo
// claro en quien tiene el oscuro elegido.
const GUARDADO = 'umind-tema'
function preferidoDelSistema() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'oscuro' : 'claro'
}
export const tema = ref(localStorage.getItem(GUARDADO) || preferidoDelSistema())
export function aplicarTema() {
document.documentElement.classList.toggle('dark', tema.value === 'oscuro')
}
export function alternarTema() {
tema.value = tema.value === 'oscuro' ? 'claro' : 'oscuro'
localStorage.setItem(GUARDADO, tema.value)
aplicarTema()
}
aplicarTema()
+1
View File
@@ -2,5 +2,6 @@ import { createApp } from 'vue'
import App from './App.vue'
import router from './router.js'
import './style.css'
import './lib/tema.js' // aplica el tema antes de montar, para no parpadear
createApp(App).use(router).mount('#app')
+2
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import Home from './views/Home.vue'
import TenantAgentes from './views/TenantAgentes.vue'
import AgenteDetail from './views/AgenteDetail.vue'
import Uso from './views/Uso.vue'
import { contexto } from './lib/contexto.js'
const router = createRouter({
@@ -9,6 +10,7 @@ const router = createRouter({
routes: [
{ path: '/', name: 'home', component: Home },
{ path: '/tenants/:id', name: 'tenant-agentes', component: TenantAgentes, props: true },
{ path: '/tenants/:id/uso', name: 'uso', component: Uso, props: true },
{
path: '/tenants/:tenantId/agentes/:agenteId',
name: 'agente-detail',
+85
View File
@@ -1,3 +1,88 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Los colores viven acá una sola vez, en claro y oscuro. Antes cada elemento
repetía el par (bg-white dark:bg-gray-900, border-gray-200 dark:border-...)
a mano en cientos de lugares; cambiar un tono era buscar y reemplazar. */
@layer base {
:root {
--superficie: 255 255 255;
--elevado: 249 250 251;
--borde: 229 231 235;
--texto: 31 41 55;
--tenue: 107 114 128;
--fondo: 249 250 251;
}
.dark {
--superficie: 17 24 39;
--elevado: 24 33 51;
--borde: 42 52 70;
--texto: 229 231 235;
--tenue: 148 163 184;
--fondo: 9 13 22;
}
body {
background: rgb(var(--fondo));
color: rgb(var(--texto));
-webkit-font-smoothing: antialiased;
}
}
@layer components {
.card {
@apply bg-superficie border border-borde rounded-xl;
}
.input {
@apply w-full bg-superficie border border-borde text-texto rounded-lg px-3 py-2 text-sm
placeholder:text-tenue/60 outline-none transition-colors
focus:border-brand focus:ring-2 focus:ring-brand/20;
}
.label {
@apply block text-xs font-medium text-tenue mb-1;
}
.btn {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium
transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-brand text-white hover:bg-brand-dark;
}
.btn-ghost {
@apply btn text-tenue hover:text-texto hover:bg-elevado;
}
.btn-peligro {
@apply btn text-red-600 hover:bg-red-50 dark:hover:bg-red-950/40;
}
.badge {
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium;
}
.badge-ok {
@apply badge bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-400;
}
.badge-alerta {
@apply badge bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400;
}
.badge-error {
@apply badge bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-400;
}
.badge-neutro {
@apply badge bg-elevado text-tenue;
}
.tab {
@apply px-3 py-1.5 rounded-full text-sm transition-colors whitespace-nowrap;
}
.tab-activo {
@apply tab bg-brand text-white;
}
.tab-inactivo {
@apply tab text-tenue hover:text-texto hover:bg-elevado;
}
}
+120 -120
View File
@@ -57,11 +57,11 @@ async function eliminarDocumento(id) {
}
const estadoColor = computed(() => (estado) => ({
listo: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400',
procesando: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400',
pendiente: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
error: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
}[estado] || 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'))
listo: 'badge-ok',
procesando: 'badge-alerta',
pendiente: 'badge-neutro',
error: 'badge-error',
}[estado] || 'badge-neutro'))
// ─── Conversaciones ──────────────────────────────────────────────────────────
const sesiones = ref([])
@@ -309,9 +309,9 @@ async function cargarEventos() {
}
const nivelColor = computed(() => (nivel) => ({
error: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
warn: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400',
}[nivel] || 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'))
error: 'badge-error',
warn: 'badge-alerta',
}[nivel] || 'badge-neutro'))
function formatearFecha(iso) {
try {
@@ -332,12 +332,12 @@ onMounted(async () => {
<template>
<div>
<router-link :to="`/tenants/${tenantId}`" class="text-xs text-gray-500 dark:text-gray-400 hover:text-brand">&larr; Agentes</router-link>
<router-link :to="`/tenants/${tenantId}`" class="label hover:text-brand">&larr; Agentes</router-link>
<div v-if="agente" class="mb-6 mt-1">
<h1 class="text-xl font-semibold text-gray-800 dark:text-gray-100">{{ agente.nombre }}</h1>
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">
site_key: <code class="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">{{ agente.site_key }}</code>
<h1 class="text-xl font-semibold text-texto">{{ agente.nombre }}</h1>
<p class="text-xs text-tenue mt-1">
site_key: <code class="bg-elevado px-1.5 py-0.5 rounded">{{ agente.site_key }}</code>
</p>
</div>
@@ -350,7 +350,7 @@ onMounted(async () => {
class="px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors"
:class="tab === key
? 'bg-brand text-white font-medium'
: 'bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-800 hover:border-brand/50'"
: 'bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 border border-borde hover:border-brand/50'"
@click="tab = key"
>
{{ label }}
@@ -360,18 +360,18 @@ onMounted(async () => {
<!-- Base de conocimiento -->
<div v-if="tab === 'conocimiento'">
<form class="flex gap-2 mb-4" @submit.prevent="agregarFuente">
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="flex-1 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
<input v-model="maxPaginas" type="number" min="1" max="200" class="w-24 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" title="Máximo de páginas a crawlear" />
<button type="submit" :disabled="ingestando" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg disabled:opacity-50 transition-colors">
<input v-model="nuevaUrl" type="url" placeholder="https://ejemplo.com" required class="flex-1 border border-borde bg-white dark:bg-gray-900 text-texto rounded-lg px-3 py-2 text-sm" />
<input v-model="maxPaginas" type="number" min="1" max="200" class="w-24 border border-borde bg-white dark:bg-gray-900 text-texto rounded-lg px-3 py-2 text-sm" title="Máximo de páginas a crawlear" />
<button type="submit" :disabled="ingestando" class="btn-primary disabled:opacity-50 transition-colors">
{{ ingestando ? 'Agregando...' : 'Crawlear sitio' }}
</button>
</form>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
<div v-if="documentos.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">Sin fuentes todavía.</div>
<div class="card divide-y divide-borde">
<div v-if="documentos.length === 0" class="p-6 text-sm text-tenue">Sin fuentes todavía.</div>
<div v-for="d in documentos" :key="d.ID" class="p-4 flex items-center justify-between">
<div>
<div class="text-sm text-gray-800 dark:text-gray-200">{{ d.origen }}</div>
<div class="text-xs text-gray-500 dark:text-gray-500 mt-0.5">
<div class="text-sm text-texto">{{ d.origen }}</div>
<div class="text-xs text-tenue mt-0.5">
<span class="px-1.5 py-0.5 rounded" :class="estadoColor(d.estado)">{{ d.estado }}</span>
<span v-if="d.total_chunks"> · {{ d.total_chunks }} fragmentos</span>
<span v-if="d.error" class="text-red-600 dark:text-red-400"> · {{ d.error }}</span>
@@ -385,61 +385,61 @@ onMounted(async () => {
<!-- Herramientas -->
<div v-else-if="tab === 'herramientas'">
<div class="flex justify-between items-center mb-4">
<p class="text-xs text-gray-500 dark:text-gray-400">Máximo 10 tools activas por agente.</p>
<button class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors" @click="nuevaTool">
<p class="label">Máximo 10 tools activas por agente.</p>
<button class="btn-primary" @click="nuevaTool">
+ Nueva tool
</button>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
<div v-if="tools.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">Sin tools custom todavía.</div>
<div class="card divide-y divide-borde">
<div v-if="tools.length === 0" class="p-6 text-sm text-tenue">Sin tools custom todavía.</div>
<div v-for="t in tools" :key="t.ID" class="p-4 flex items-center justify-between">
<div>
<div class="text-sm text-gray-800 dark:text-gray-200 font-mono">{{ t.nombre }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ t.descripcion }}</div>
<div class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
<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-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100" @click="editarTool(t)">Editar</button>
<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/40 flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">{{ editingTool ? 'Editar tool' : 'Nueva tool' }}</h2>
<div v-if="showToolForm" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
<div class="card p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
<h2 class="font-semibold text-texto mb-4">{{ editingTool ? 'Editar tool' : 'Nueva tool' }}</h2>
<form class="space-y-3" @submit.prevent="guardarTool">
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Nombre (identificador, ej: consultar_stock)</label>
<input v-model="toolForm.nombre" required pattern="[a-z][a-z0-9_]{2,63}" class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm font-mono" />
<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="text-xs text-gray-500 dark:text-gray-400">Descripción (esto lo lee el modelo para decidir cuándo usarla)</label>
<textarea v-model="toolForm.descripcion" rows="2" required class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"></textarea>
<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="text-xs text-gray-500 dark:text-gray-400">URL del webhook (https)</label>
<input v-model="toolForm.url" type="url" required placeholder="https://..." class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
<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-gray-200 dark:border-gray-800 rounded-lg p-3 space-y-2">
<div class="border border-borde rounded-lg p-3 space-y-2">
<div class="flex items-center justify-between">
<label class="text-xs text-gray-500 dark:text-gray-400">Parámetros que completa el modelo</label>
<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-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded px-2 py-1 text-xs font-mono" />
<select v-model="p.tipo" class="border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded px-2 py-1 text-xs">
<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-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded px-2 py-1 text-xs" />
<label class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<input v-model="p.descripcion" placeholder="descripción" class="flex-1 border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs" />
<label class="label flex items-center gap-1">
<input v-model="p.requerido" type="checkbox" /> req.
</label>
<button type="button" class="text-red-400 text-xs" @click="quitarParametro(i)"></button>
@@ -447,10 +447,10 @@ onMounted(async () => {
<p v-if="toolForm.parametros.length === 0" class="text-xs text-gray-400">Sin parámetros.</p>
</div>
<div class="border border-gray-200 dark:border-gray-800 rounded-lg p-3 space-y-2">
<label class="text-xs text-gray-500 dark:text-gray-400">Autenticación saliente (opcional)</label>
<input v-model="toolForm.auth_header_nombre" placeholder="Nombre del header, ej: Authorization" class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
<label class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<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>
@@ -459,16 +459,16 @@ onMounted(async () => {
v-model="toolForm.auth_header_valor"
type="password"
placeholder="Valor del header (ej: Bearer xxxx)"
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
class="input"
/>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<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="px-4 py-2 text-sm text-gray-500 dark:text-gray-400" @click="showToolForm = false">Cancelar</button>
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">Guardar</button>
<button type="button" class="btn-ghost" @click="showToolForm = false">Cancelar</button>
<button type="submit" class="btn-primary">Guardar</button>
</div>
</form>
</div>
@@ -477,11 +477,11 @@ onMounted(async () => {
<!-- Canales -->
<div v-else-if="tab === 'canales'">
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 mb-4">
<div class="card p-4 mb-4">
<div class="flex items-center justify-between mb-2">
<div>
<span class="font-medium text-gray-800 dark:text-gray-200">Web (widget)</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400">
<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>
@@ -493,102 +493,102 @@ onMounted(async () => {
{{ widgetCopiado ? '✓ Copiado' : 'Copiar código' }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
Pegá esto antes de <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">&lt;/body&gt;</code> en las páginas de tu sitio.
<p class="label mb-2">
Pegá esto antes de <code class="bg-elevado px-1 rounded">&lt;/body&gt;</code> en las páginas de tu sitio.
</p>
<pre class="bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg p-2.5 text-xs text-gray-700 dark:text-gray-300 overflow-x-auto"><code>{{ widgetSnippet }}</code></pre>
<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="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors" @click="nuevoCanal">
<button class="btn-primary" @click="nuevoCanal">
+ Nuevo canal
</button>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
<div v-if="canales.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">Sin canales configurados.</div>
<div class="card divide-y divide-borde">
<div v-if="canales.length === 0" class="p-6 text-sm text-tenue">Sin canales configurados.</div>
<div v-for="c in canales" :key="c.ID" class="p-4">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-gray-800 dark:text-gray-200 capitalize">{{ c.tipo }}</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'">
<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-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100" @click="toggleCanal(c)">
<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-gray-600 dark:text-gray-300 cursor-pointer">
<input type="checkbox" :checked="c.usar_whisper_audio" @change="toggleCanalWhisper(c)" class="rounded border-gray-300 dark:border-gray-700 text-brand focus:ring-brand" />
<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-gray-600 dark:text-gray-300 cursor-pointer">
<input type="checkbox" :checked="c.usar_ocr_imagenes" @change="toggleCanalOcr(c)" class="rounded border-gray-300 dark:border-gray-700 text-brand focus:ring-brand" />
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
<input type="checkbox" :checked="c.usar_ocr_imagenes" @change="toggleCanalOcr(c)" class="rounded border-borde text-brand focus:ring-brand" />
Leer texto de imágenes (OCR)
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 break-all">
Webhook: <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">{{ c.webhook_url }}</code>
<p 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-gray-400 dark:text-gray-500 mt-1">
<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/40 flex items-center justify-center p-4 z-50" @click.self="showCanalForm = false">
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-md">
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">Nuevo canal</h2>
<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="text-xs text-gray-500 dark:text-gray-400">Tipo</label>
<select v-model="canalForm.tipo" class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm">
<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="text-xs text-gray-500 dark:text-gray-400">Bot token (de @BotFather)</label>
<input v-model="canalForm.bot_token" type="password" required class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
<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="text-xs text-gray-500 dark:text-gray-400">Phone Number ID</label>
<input v-model="canalForm.phone_number_id" required class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
<label class="label">Phone Number ID</label>
<input v-model="canalForm.phone_number_id" required class="input" />
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Access Token</label>
<input v-model="canalForm.access_token" type="password" required class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
<label class="label">Access Token</label>
<input v-model="canalForm.access_token" type="password" required class="input" />
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">App Secret</label>
<input v-model="canalForm.app_secret" type="password" required class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
<label class="label">App Secret</label>
<input v-model="canalForm.app_secret" type="password" required class="input" />
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Verify Token (lo inventás vos, lo vas a usar en Meta)</label>
<input v-model="canalForm.verify_token" required class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
<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-gray-600 dark:text-gray-300 cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_whisper_audio" class="rounded border-gray-300 dark:border-gray-700 text-brand focus:ring-brand" />
<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-gray-600 dark:text-gray-300 cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_ocr_imagenes" class="rounded border-gray-300 dark:border-gray-700 text-brand focus:ring-brand" />
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_ocr_imagenes" class="rounded border-borde text-brand focus:ring-brand" />
Leer texto de imágenes con OCR
</label>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400" @click="showCanalForm = false">Cancelar</button>
<button type="submit" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg">Guardar</button>
<button type="button" class="btn-ghost" @click="showCanalForm = false">Cancelar</button>
<button type="submit" class="btn-primary">Guardar</button>
</div>
</form>
</div>
@@ -597,25 +597,25 @@ onMounted(async () => {
<!-- Conexiones (correo, OAuth) -->
<div v-else-if="tab === 'conexiones'">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
<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-gray-300 dark:border-gray-700 hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('google')">
<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-gray-300 dark:border-gray-700 hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('microsoft')">
<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="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
<div v-if="conexiones.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">Sin cuentas conectadas.</div>
<div class="card divide-y divide-borde">
<div v-if="conexiones.length === 0" class="p-6 text-sm text-tenue">Sin cuentas conectadas.</div>
<div v-for="c in conexiones" :key="c.ID" class="p-4 flex items-center justify-between">
<div>
<span class="font-medium text-gray-800 dark:text-gray-200 capitalize">{{ c.proveedor }}</span>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">{{ c.email }}</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'">
<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>
@@ -625,24 +625,24 @@ onMounted(async () => {
</div>
<!-- Chat de prueba -->
<div v-else-if="tab === 'chat'" class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 flex flex-col h-[28rem]">
<div v-else-if="tab === 'chat'" class="card p-4 flex flex-col h-[28rem]">
<div class="flex-1 overflow-y-auto space-y-2 mb-3">
<p v-if="chatMensajes.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
<p v-if="chatMensajes.length === 0" class="text-sm text-tenue">
Probá este agente tal cual lo va a ver un visitante usa la misma config de IA y las mismas tools/base de conocimiento.
</p>
<div
v-for="(m, i) in chatMensajes"
:key="i"
class="max-w-[80%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap"
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-100'"
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-elevado text-texto'"
>
{{ m.content }}
</div>
<p v-if="chatEnviando" class="text-xs text-gray-400 dark:text-gray-500">Pensando...</p>
<p v-if="chatEnviando" class="text-xs text-tenue">Pensando...</p>
</div>
<form class="flex gap-2" @submit.prevent="enviarChatPrueba">
<input v-model="chatInput" placeholder="Escribí un mensaje de prueba..." class="flex-1 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
<button type="submit" :disabled="chatEnviando" class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg disabled:opacity-50 transition-colors">
<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>
@@ -650,26 +650,26 @@ onMounted(async () => {
<!-- Conversaciones -->
<div v-else-if="tab === 'conversaciones'" class="grid grid-cols-3 gap-4">
<div class="col-span-1 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800 max-h-[28rem] overflow-y-auto">
<div v-if="sesiones.length === 0" class="p-4 text-sm text-gray-500 dark:text-gray-400">Sin conversaciones.</div>
<div class="col-span-1 card divide-y divide-borde max-h-[28rem] overflow-y-auto">
<div v-if="sesiones.length === 0" class="p-4 text-sm text-tenue">Sin conversaciones.</div>
<button
v-for="s in sesiones"
:key="s.session_id"
class="w-full text-left p-3 hover:bg-gray-50 dark:hover:bg-gray-800 text-sm"
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-gray-800 dark:text-gray-200 truncate">{{ s.content }}</div>
<div class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{{ s.session_id }}</div>
<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 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 max-h-[28rem] overflow-y-auto space-y-2">
<p v-if="!sesionActiva" class="text-sm text-gray-500 dark:text-gray-400">Elegí una conversación de la izquierda.</p>
<div 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-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-100'"
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-elevado text-texto'"
>
{{ m.content }}
</div>
@@ -678,19 +678,19 @@ onMounted(async () => {
<!-- Auditoría -->
<div v-else>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
<p class="label mb-4">
Errores y eventos técnicos de este agente fallos al llamar al AI, a tools, a correo o a los canales. Últimos 100.
</p>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800 max-h-[32rem] overflow-y-auto">
<div v-if="eventos.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">Sin eventos registrados buena señal.</div>
<div class="card divide-y divide-borde max-h-[32rem] overflow-y-auto">
<div v-if="eventos.length === 0" class="p-6 text-sm text-tenue">Sin eventos registrados buena señal.</div>
<details v-for="e in eventos" :key="e.ID" class="p-3">
<summary class="cursor-pointer flex items-center gap-2 text-sm">
<span class="px-1.5 py-0.5 rounded text-xs shrink-0" :class="nivelColor(e.nivel)">{{ e.nivel }}</span>
<span class="text-gray-400 dark:text-gray-500 text-xs shrink-0">{{ e.origen }}</span>
<span class="text-gray-800 dark:text-gray-200 truncate">{{ e.mensaje }}</span>
<span class="text-gray-400 dark:text-gray-500 text-xs ml-auto shrink-0">{{ formatearFecha(e.CreatedAt) }}</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-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg p-2 text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap">{{ e.detalle }}</pre>
<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>
+31 -24
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
const props = defineProps({ id: { type: String, required: true } })
const tenantId = computed(() => Number(props.id))
@@ -83,41 +84,47 @@ onMounted(cargar)
<template>
<div>
<div v-if="tenant" class="mb-6">
<h1 class="text-xl font-semibold text-gray-800 dark:text-gray-100">{{ tenant.nombre }}</h1>
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">{{ tenant.dominios_permitidos || 'sin dominios configurados' }}</p>
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-texto">{{ tenant.nombre }}</h1>
<p class="text-xs text-tenue mt-1">{{ tenant.dominios_permitidos || 'sin dominios configurados' }}</p>
</div>
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost">📊 Consumo</router-link>
</div>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-medium text-gray-600 dark:text-gray-300">Agentes</h2>
<button class="bg-brand hover:bg-brand-dark text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors" @click="nuevoAgente">
+ Nuevo agente
</button>
<h2 class="text-sm font-medium text-tenue">Agentes</h2>
<button class="btn-primary" @click="nuevoAgente">+ Nuevo agente</button>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
<div v-if="agentes.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">
Todavía no hay agentes. Creá el primero.
</div>
<div class="card divide-y divide-borde overflow-hidden">
<UiEmptyState
v-if="agentes.length === 0"
icono="🤖"
titulo="Todavía no hay agentes"
detalle="Creá el primero y cargale su base de conocimiento para que empiece a responder."
/>
<div
v-for="a in agentes"
:key="a.ID"
class="group flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-800"
class="group flex items-center justify-between hover:bg-elevado transition-colors"
>
<router-link :to="`/tenants/${tenantId}/agentes/${a.ID}`" class="flex-1 min-w-0 p-4">
<div class="font-medium text-gray-800 dark:text-gray-200">{{ a.nombre }}</div>
<div class="font-medium text-texto">{{ a.nombre }}</div>
<div class="flex items-center gap-1 mt-0.5">
<span class="w-1.5 h-1.5 rounded-full" :style="{ background: a.activo ? a.color || '#22c55e' : undefined }" :class="!a.activo && 'bg-gray-300 dark:bg-gray-600'"></span>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ a.activo ? 'activo' : 'inactivo' }}</span>
<span class="text-xs text-tenue">{{ a.activo ? 'activo' : 'inactivo' }}</span>
</div>
</router-link>
<div class="flex items-center gap-3 pr-4 text-sm">
<router-link :to="`/tenants/${tenantId}/agentes/${a.ID}`" class="text-brand font-medium opacity-0 group-hover:opacity-100 transition-opacity">
Configurar &rarr;
</router-link>
<button class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 opacity-0 group-hover:opacity-100 transition-opacity" title="Editar" @click="editarAgente(a)"></button>
<button class="text-gray-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity" title="Eliminar" @click="eliminarAgente(a)"></button>
<button class="text-tenue hover:text-texto opacity-0 group-hover:opacity-100 transition-opacity" title="Editar" @click="editarAgente(a)"></button>
<button class="text-tenue hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity" title="Eliminar" @click="eliminarAgente(a)"></button>
</div>
</div>
</div>
@@ -127,26 +134,26 @@ onMounted(cargar)
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">{{ editing ? 'Editar agente' : 'Nuevo agente' }}</h2>
<form class="space-y-3" @submit.prevent="guardar">
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Nombre</label>
<input v-model="form.nombre" required placeholder="ej: Ventas, Soporte" class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
<label class="label">Nombre</label>
<input v-model="form.nombre" required placeholder="ej: Ventas, Soporte" class="input" />
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Config de IA</label>
<select v-model="form.ai_config_id" class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm">
<label class="label">Config de IA</label>
<select v-model="form.ai_config_id" class="input">
<option :value="null"> sin asignar </option>
<option v-for="c in aiConfigs" :key="c.ID" :value="c.ID">{{ c.nombre }} ({{ c.provider }})</option>
</select>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Tono / personalidad</label>
<textarea v-model="form.tono" rows="2" class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"></textarea>
<label class="label">Tono / personalidad</label>
<textarea v-model="form.tono" rows="2" class="input"></textarea>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Mensaje de bienvenida</label>
<input v-model="form.mensaje_bienvenida" class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm" />
<label class="label">Mensaje de bienvenida</label>
<input v-model="form.mensaje_bienvenida" class="input" />
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Color del widget</label>
<label class="label">Color del widget</label>
<div class="flex items-center gap-2">
<input v-model="form.color" type="color" class="w-10 h-9 border border-gray-300 dark:border-gray-700 rounded cursor-pointer bg-white dark:bg-gray-800" />
<input v-model="form.color" type="text" pattern="#[0-9a-fA-F]{6}" class="flex-1 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm font-mono" />
+198
View File
@@ -0,0 +1,198 @@
<script setup>
import { computed, onMounted, 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({ id: { type: String, required: true } })
const cargando = ref(true)
const error = ref('')
const datos = ref(null)
// Por defecto, el mes en curso.
const hoy = new Date()
const desde = ref(new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().slice(0, 10))
const hasta = ref(hoy.toISOString().slice(0, 10))
const ETIQUETAS = {
ia: { nombre: 'Inteligencia artificial', icono: '🧠', color: '#8eb02f' },
ocr: { nombre: 'Lectura de imágenes', icono: '🖼️', color: '#2f7fb0' },
whisper: { nombre: 'Transcripción de audio', icono: '🎙️', color: '#b0752f' },
}
const etiqueta = (t) => ETIQUETAS[t] || { nombre: t, icono: '•', color: '#94a3b8' }
async function cargar() {
cargando.value = true
error.value = ''
try {
datos.value = await api.get(
apiUmind(`/umind/uso?tenant_id=${props.id}&desde=${desde.value}&hasta=${hasta.value}`),
)
} catch (e) {
error.value = e.message
} finally {
cargando.value = false
}
}
const moneda = computed(() => datos.value?.plan?.moneda || 'COP')
const resumen = computed(() => datos.value?.resumen || [])
const totalPeriodo = computed(() => datos.value?.total_periodo || 0)
const tope = computed(() => datos.value?.plan?.tope_consumo_mensual || 0)
const porcentajeTope = computed(() => {
if (!tope.value) return 0
return Math.min(100, (totalPeriodo.value / tope.value) * 100)
})
function money(v) {
return `${moneda.value} ${Number(v || 0).toLocaleString('es-CO', { maximumFractionDigits: 2 })}`
}
function numero(v) {
return Number(v || 0).toLocaleString('es-CO', { maximumFractionDigits: 0 })
}
// Consumo por día, para el gráfico. Se arma desde el detalle en vez de pedirle
// otro agregado al backend: son a lo sumo 1000 filas y evita un endpoint más.
const porDia = computed(() => {
const mapa = new Map()
for (const u of datos.value?.detalle || []) {
const dia = String(u.CreatedAt).slice(0, 10)
mapa.set(dia, (mapa.get(dia) || 0) + (u.costo || 0))
}
const dias = [...mapa.entries()].sort((a, b) => a[0].localeCompare(b[0]))
const max = Math.max(...dias.map((d) => d[1]), 0)
return { dias, max }
})
function atajo(dias) {
const fin = new Date()
const ini = new Date()
ini.setDate(ini.getDate() - dias)
desde.value = ini.toISOString().slice(0, 10)
hasta.value = fin.toISOString().slice(0, 10)
cargar()
}
onMounted(cargar)
</script>
<template>
<div>
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<h1 class="text-xl font-semibold text-texto">Consumo</h1>
<p class="text-sm text-tenue mt-0.5">Lo que usaron tus agentes y cuánto cuesta.</p>
</div>
<router-link :to="`/tenants/${id}`" class="btn-ghost"> Agentes</router-link>
</div>
<!-- Filtros -->
<div class="card p-4 mb-4">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="label">Desde</label>
<input v-model="desde" type="date" class="input !w-auto" />
</div>
<div>
<label class="label">Hasta</label>
<input v-model="hasta" type="date" class="input !w-auto" />
</div>
<button class="btn-primary" @click="cargar">Aplicar</button>
<div class="flex gap-1 ml-auto">
<button class="btn-ghost !px-2.5 !py-1 !text-xs" @click="atajo(6)">7 días</button>
<button class="btn-ghost !px-2.5 !py-1 !text-xs" @click="atajo(29)">30 días</button>
<button class="btn-ghost !px-2.5 !py-1 !text-xs" @click="atajo(89)">90 días</button>
</div>
</div>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<p v-if="cargando" class="text-sm text-tenue">Cargando</p>
<template v-else-if="datos">
<!-- Totales -->
<div class="grid gap-3 sm:grid-cols-3 mb-4">
<div class="card p-4">
<p class="text-xs text-tenue">Consumo del período</p>
<p class="text-2xl font-semibold text-texto mt-1 tabular-nums">{{ money(totalPeriodo) }}</p>
</div>
<div class="card p-4">
<p class="text-xs text-tenue">Pendiente de facturar</p>
<p class="text-2xl font-semibold text-texto mt-1 tabular-nums">
{{ money(datos.pendiente_facturar) }}
</p>
<p class="text-[11px] text-tenue mt-0.5">Se suma a tu próxima factura.</p>
</div>
<div class="card p-4">
<p class="text-xs text-tenue">Plan</p>
<p class="text-lg font-semibold text-texto mt-1">{{ datos.plan?.nombre || 'Sin plan' }}</p>
<p v-if="datos.plan" class="text-[11px] text-tenue mt-0.5">
Mensualidad {{ money(datos.plan.precio_mensual) }}
</p>
</div>
</div>
<!-- Tope -->
<div v-if="tope" class="card p-4 mb-4">
<div class="flex items-center justify-between text-sm mb-2">
<span class="text-texto">Tope mensual</span>
<span class="text-tenue tabular-nums">{{ money(totalPeriodo) }} / {{ money(tope) }}</span>
</div>
<div class="h-2 rounded-full bg-elevado overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="porcentajeTope >= 100 ? 'bg-red-500' : porcentajeTope >= 80 ? 'bg-amber-500' : 'bg-brand'"
:style="{ width: porcentajeTope + '%' }"
></div>
</div>
<p v-if="porcentajeTope >= 100" class="text-xs text-amber-600 dark:text-amber-400 mt-2">
Superaste el tope de tu plan. El servicio sigue funcionando y el excedente se cobra en el próximo ciclo.
</p>
</div>
<!-- Desglose por tipo -->
<div class="card divide-y divide-borde mb-4">
<div class="px-4 py-3">
<h2 class="text-sm font-medium text-texto">Desglose</h2>
</div>
<UiEmptyState
v-if="resumen.length === 0"
icono="📊"
titulo="Sin consumo en este período"
detalle="Cuando tus agentes respondan mensajes, lean imágenes o transcriban audios, vas a verlo acá."
/>
<div v-for="r in resumen" :key="r.tipo" class="px-4 py-3 flex items-center gap-3">
<span class="text-lg">{{ etiqueta(r.tipo).icono }}</span>
<div class="min-w-0 flex-1">
<p class="text-sm text-texto">{{ etiqueta(r.tipo).nombre }}</p>
<p class="text-xs text-tenue tabular-nums">
{{ numero(r.cantidad) }} {{ r.unidad }} · {{ numero(r.eventos) }} usos
</p>
</div>
<span class="text-sm font-medium text-texto tabular-nums">{{ money(r.costo) }}</span>
</div>
</div>
<!-- Consumo por día: SVG a mano, no vale una librería de charts por esto -->
<div v-if="porDia.dias.length" class="card p-4">
<h2 class="text-sm font-medium text-texto mb-3">Por día</h2>
<div class="flex items-end gap-1 h-28">
<div
v-for="[dia, costo] in porDia.dias"
:key="dia"
class="flex-1 min-w-[3px] bg-brand/70 hover:bg-brand rounded-t transition-colors"
:style="{ height: porDia.max ? Math.max(2, (costo / porDia.max) * 100) + '%' : '2px' }"
:title="`${dia}: ${money(costo)}`"
></div>
</div>
<div class="flex justify-between text-[11px] text-tenue mt-2">
<span>{{ porDia.dias[0][0] }}</span>
<span>{{ porDia.dias[porDia.dias.length - 1][0] }}</span>
</div>
</div>
</template>
</div>
</template>