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:
co-authored by
Claude Sonnet 5
parent
023a8af494
commit
d8f9acd1f8
@@ -1297,16 +1297,15 @@ func SeedUmind() {
|
||||
return
|
||||
}
|
||||
// El panel viejo (Alpine, /app/umind) sigue existiendo y respondiendo, pero
|
||||
// el menú ahora apunta al orquestador (SPA Vue). Si ya existía el submódulo
|
||||
// apuntando a la URL vieja, se migra en el mismo registro en vez de crear
|
||||
// uno duplicado.
|
||||
url := "/orchestrator"
|
||||
urlVieja := "/app/umind"
|
||||
// el menú apunta al SPA. La URL pasó por /app/umind → /orchestrator →
|
||||
// /studio: se busca por cualquiera de las tres y se migra el mismo
|
||||
// registro, en vez de dejar submódulos duplicados en el menú.
|
||||
url := "/studio"
|
||||
var sub models.Submodules
|
||||
if err := db.Where("url = ? OR url = ?", url, urlVieja).First(&sub).Error; err != nil {
|
||||
if err := db.Where("url IN ?", []string{url, "/orchestrator", "/app/umind"}).First(&sub).Error; err != nil {
|
||||
sub = models.Submodules{
|
||||
Title: "uMind",
|
||||
Description: "Chat con IA embebible por sitio, con base de conocimiento propia (RAG)",
|
||||
Title: "uMind Studio",
|
||||
Description: "Creación y gestión de agentes de IA: conocimiento, canales, herramientas y consumo",
|
||||
Url: url,
|
||||
ModuleId: modulo.ID,
|
||||
ModifiedAt: time.Now(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">← Agentes</router-link>
|
||||
<router-link :to="`/tenants/${tenantId}`" class="label hover:text-brand">← 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"></body></code> en las páginas de tu sitio.
|
||||
<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-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>
|
||||
|
||||
@@ -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 →
|
||||
</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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -1,6 +1,8 @@
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,js}'],
|
||||
darkMode: 'media',
|
||||
// 'class' (y no 'media') para que el toggle del header pueda ganarle al
|
||||
// sistema operativo. La clase la pone lib/tema.js antes de montar la app.
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
@@ -8,7 +10,32 @@ export default {
|
||||
brand: {
|
||||
DEFAULT: '#8eb02f',
|
||||
dark: '#719026',
|
||||
light: '#a8c94f',
|
||||
},
|
||||
// Superficies y texto salen de variables CSS (ver style.css) para no
|
||||
// repetir el par claro/oscuro en cada elemento.
|
||||
superficie: 'rgb(var(--superficie) / <alpha-value>)',
|
||||
elevado: 'rgb(var(--elevado) / <alpha-value>)',
|
||||
borde: 'rgb(var(--borde) / <alpha-value>)',
|
||||
texto: 'rgb(var(--texto) / <alpha-value>)',
|
||||
tenue: 'rgb(var(--tenue) / <alpha-value>)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'Segoe UI', 'sans-serif'],
|
||||
},
|
||||
keyframes: {
|
||||
aparecer: {
|
||||
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
escalar: {
|
||||
'0%': { opacity: '0', transform: 'scale(.97)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
aparecer: 'aparecer .2s ease-out',
|
||||
escalar: 'escalar .15s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>uMind Studio</title>
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-DPluLgCZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-C6K_wyko.css">
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-DP70_OF3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-CkF90HJJ.css">
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -59,7 +59,7 @@ func UmindConectarHandler(c *fiber.Ctx) error {
|
||||
func UmindOAuthCallbackHandler(c *fiber.Ctx) error {
|
||||
proveedor := c.Params("proveedor")
|
||||
if errParam := c.Query("error"); errParam != "" {
|
||||
return c.Redirect(fmt.Sprintf("/orchestrator/?oauth_error=%s", errParam), fiber.StatusFound)
|
||||
return c.Redirect(fmt.Sprintf("/studio/?oauth_error=%s", errParam), fiber.StatusFound)
|
||||
}
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
@@ -70,13 +70,13 @@ func UmindOAuthCallbackHandler(c *fiber.Ctx) error {
|
||||
conexion, err := services.CompletarConexionOAuth(proveedor, code, state)
|
||||
if err != nil {
|
||||
log.Printf("[UMIND_OAUTH] error completando conexión (%s): %v", proveedor, err)
|
||||
return c.Redirect("/orchestrator/?oauth_error=1", fiber.StatusFound)
|
||||
return c.Redirect("/studio/?oauth_error=1", fiber.StatusFound)
|
||||
}
|
||||
agente, err := models.GetUmindAgenteByID(conexion.AgenteID)
|
||||
if err != nil {
|
||||
return c.Redirect("/orchestrator/?oauth_error=1", fiber.StatusFound)
|
||||
return c.Redirect("/studio/?oauth_error=1", fiber.StatusFound)
|
||||
}
|
||||
return c.Redirect(fmt.Sprintf("/orchestrator/tenants/%d/agentes/%d?tab=conexiones", agente.TenantID, agente.ID), fiber.StatusFound)
|
||||
return c.Redirect(fmt.Sprintf("/studio/tenants/%d/agentes/%d?tab=conexiones", agente.TenantID, agente.ID), fiber.StatusFound)
|
||||
}
|
||||
|
||||
func DeleteUmindConexionHandler(c *fiber.Ctx) error {
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ func WebAuthRoutes(App fiber.Router) {
|
||||
)
|
||||
// Generar contraseñas aleatorias /do/generate-password
|
||||
App.Post("/do/generate-password",
|
||||
controllers.GeneratePasswordPost,
|
||||
controllers.GeneratePasswordPost,
|
||||
)
|
||||
|
||||
App.Get("/request-password-reset", middlewares.RedirectToHomePageOnLogin, controllers.RequestPasswordReset)
|
||||
|
||||
@@ -12,7 +12,6 @@ func LandingRoutes(web fiber.Router) {
|
||||
web.Get("/ping", Pong)
|
||||
web.Get("/all-routes", AllRoutes)
|
||||
web.Get("/do/verify-email", middlewares.ValidateConfirmToken, controllers.VerifyRegisteredEmail)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package routes
|
||||
|
||||
import "strings"
|
||||
|
||||
// destinoStudio traduce una URL vieja de /orchestrator a la nueva de /studio,
|
||||
// conservando la ruta del lado del cliente (vue-router) para que un enlace
|
||||
// guardado a un agente puntual siga cayendo en ese agente.
|
||||
func destinoStudio(path string) string {
|
||||
return "/studio" + strings.TrimPrefix(path, "/orchestrator")
|
||||
}
|
||||
|
||||
// esAssetDelBundle indica si la URL es un estático del build. El `base` de
|
||||
// Vite sigue siendo /orchestrator/, así que esos NO se redirigen: hacerlo
|
||||
// rompería el SPA. En la práctica el Static("/") general los atiende antes,
|
||||
// pero el chequeo explícito evita depender del orden de registro de rutas.
|
||||
func esAssetDelBundle(path string) bool {
|
||||
return strings.HasPrefix(path, "/orchestrator/assets/")
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package routes
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDestinoStudio(t *testing.T) {
|
||||
casos := map[string]string{
|
||||
"/orchestrator": "/studio",
|
||||
"/orchestrator/": "/studio/",
|
||||
"/orchestrator/tenants/3": "/studio/tenants/3",
|
||||
"/orchestrator/tenants/3/agentes/11": "/studio/tenants/3/agentes/11",
|
||||
"/orchestrator/tenants/3/uso": "/studio/tenants/3/uso",
|
||||
}
|
||||
for entrada, esperado := range casos {
|
||||
if got := destinoStudio(entrada); got != esperado {
|
||||
t.Errorf("destinoStudio(%q) = %q, esperaba %q", entrada, got, esperado)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redirigir los assets rompería el SPA: el `base` del bundle sigue siendo
|
||||
// /orchestrator/, así que el JS y el CSS tienen que seguir sirviéndose ahí.
|
||||
func TestAssetsDelBundleNoSeRedirigen(t *testing.T) {
|
||||
if !esAssetDelBundle("/orchestrator/assets/index-abc123.js") {
|
||||
t.Error("un asset del bundle debería quedar exento del redirect")
|
||||
}
|
||||
if esAssetDelBundle("/orchestrator/tenants/3") {
|
||||
t.Error("una ruta de navegación no es un asset y debe redirigirse")
|
||||
}
|
||||
}
|
||||
+19
-4
@@ -371,15 +371,30 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Put("/umind-planes/:id", middlewares.SoloAdmin, controllers.UpdateUmindPlanHandler)
|
||||
protected.Delete("/umind-planes/:id", middlewares.SoloAdmin, controllers.DeleteUmindPlanHandler)
|
||||
|
||||
// ─── uMind Orquestador (SPA Vue) ────────────────────────────────────────────
|
||||
// ─── uMind Studio (SPA Vue) ─────────────────────────────────────────────────
|
||||
// Estáticos reales (JS/CSS del build) ya los sirve el Static("/") general
|
||||
// registrado en config.LoadStatic — esto es solo el fallback para las rutas
|
||||
// del lado del cliente (vue-router en modo history), protegido con sesión.
|
||||
orchestratorFallback := func(c *fiber.Ctx) error {
|
||||
studioFallback := func(c *fiber.Ctx) error {
|
||||
return c.SendFile("./public/orchestrator/index.html")
|
||||
}
|
||||
app.Get("/orchestrator", middlewares.AuthWeb(), orchestratorFallback)
|
||||
app.Get("/orchestrator/*", middlewares.AuthWeb(), orchestratorFallback)
|
||||
app.Get("/studio", middlewares.AuthWeb(), studioFallback)
|
||||
app.Get("/studio/*", middlewares.AuthWeb(), studioFallback)
|
||||
|
||||
// /orchestrator era la URL vieja: redirige para no romper los enlaces que
|
||||
// alguien haya guardado. Los assets del build siguen viviendo bajo
|
||||
// /orchestrator/assets (es el base del bundle), así que solo se redirige
|
||||
// la navegación, no los estáticos.
|
||||
redirigirAStudio := func(c *fiber.Ctx) error {
|
||||
return c.Redirect(destinoStudio(c.Path()), fiber.StatusMovedPermanently)
|
||||
}
|
||||
app.Get("/orchestrator", middlewares.AuthWeb(), redirigirAStudio)
|
||||
app.Get("/orchestrator/*", middlewares.AuthWeb(), func(c *fiber.Ctx) error {
|
||||
if esAssetDelBundle(c.Path()) {
|
||||
return c.Next()
|
||||
}
|
||||
return redirigirAStudio(c)
|
||||
})
|
||||
|
||||
// ─── OSS API (Alibaba Cloud + S3/MinIO) ────────────────────────────────────
|
||||
protected.Get("/oss-api", middlewares.MenuMiddleware, controllers.OssApiIndex)
|
||||
|
||||
Reference in New Issue
Block a user