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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user