feat(studio): que se note que hay una IA atrás, sin fondo animado permanente
Un fondo animado corriendo todo el día en una herramienta de trabajo se ve bien diez segundos y molesta las ocho horas siguientes, además de tener un canvas comiéndose la batería. Así que el movimiento va donde hay algo pasando de verdad, y el fondo queda quieto. Al entrar, una vez por sesión: el campo de puntos conectándose durante poco más de un segundo, y se destruye. Es el mismo lenguaje visual de la página pública —puntos que se enlazan, como el espacio vectorial donde vive el conocimiento— así que refuerza la marca en vez de inventar algo para adentro. En los momentos de espera, que es donde el producto se veía muerto: el chat de prueba decía "Pensando..." en gris y ahora muestra a Umi con los tres puntos, el mismo gesto que ve el visitante en el widget. Y una fuente en "procesando" tenía la etiqueta quieta, sin decir si seguía avanzando o se había colgado: ahora late. El fondo es una retícula de plano técnico, tenue y estática, que se desvanece hacia abajo para no competir con las tablas y los formularios. Dos cosas que aparecieron al verificar: El apagado de la animación de entrada estaba dentro del requestAnimationFrame, que el navegador no corre en pestañas de fondo — abrir el Studio en una pestaña que no se está mirando dejaba el canvas puesto para siempre, tapando la interfaz con una capa invisible. Ahora el temporizador se programa aparte. Y una fuente sin procesar mostraba "leído sin procesar", que no quiere decir nada: si no hay fecha de lectura, no se escribe la frase. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b97814d62d
commit
002b75e71b
@@ -2,10 +2,12 @@
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import { tema, alternarTema } from './lib/tema.js'
|
||||
import { menuAbierto } from './lib/ui.js'
|
||||
import DespertarUmind from './components/DespertarUmind.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex">
|
||||
<DespertarUmind />
|
||||
<Sidebar />
|
||||
<main class="flex-1 min-w-0 flex flex-col">
|
||||
<header class="h-14 shrink-0 flex items-center gap-1 px-4 sm:px-6 border-b border-borde">
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
// El campo de puntos conectándose, una sola vez al entrar. Es el mismo lenguaje
|
||||
// visual de la página pública —puntos que se enlazan, como el espacio vectorial
|
||||
// donde vive el conocimiento del agente— así que refuerza la marca en vez de
|
||||
// inventar algo nuevo para adentro.
|
||||
//
|
||||
// Dura poco más de un segundo y se destruye. Un fondo animado permanente en una
|
||||
// herramienta de trabajo se ve bien diez segundos y molesta las ocho horas
|
||||
// siguientes, además de tener un canvas comiendo batería todo el día.
|
||||
const CLAVE = 'umind_despertar_visto'
|
||||
const DURACION = 1250
|
||||
|
||||
const lienzo = ref(null)
|
||||
const visible = ref(false)
|
||||
let raf = null
|
||||
let apagar = null
|
||||
|
||||
function quieto() {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Una vez por sesión: verlo en cada navegación del SPA sería exactamente el
|
||||
// fondo permanente que se quiere evitar.
|
||||
if (quieto() || sessionStorage.getItem(CLAVE)) return
|
||||
sessionStorage.setItem(CLAVE, '1')
|
||||
visible.value = true
|
||||
|
||||
// El apagado se programa ACÁ y no dentro del requestAnimationFrame: en una
|
||||
// pestaña de fondo el navegador no corre rAF, así que el timeout nunca
|
||||
// llegaba a programarse y el canvas quedaba puesto para siempre. Abrir el
|
||||
// Studio en una pestaña que no se está mirando alcanzaba para dejarlo tapado
|
||||
// con una capa invisible.
|
||||
apagar = setTimeout(() => (visible.value = false), DURACION)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const cv = lienzo.value
|
||||
if (!cv) return
|
||||
const ctx = cv.getContext('2d')
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const w = window.innerWidth
|
||||
const h = window.innerHeight
|
||||
cv.width = Math.round(w * dpr)
|
||||
cv.height = Math.round(h * dpr)
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
|
||||
// Menos puntos en pantallas chicas: en un teléfono la misma densidad se ve
|
||||
// sucia y cuesta más de dibujar.
|
||||
const cantidad = w < 640 ? 22 : 46
|
||||
const puntos = Array.from({ length: cantidad }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
r: 1 + Math.random() * 1.6,
|
||||
demora: Math.random() * 0.45,
|
||||
}))
|
||||
|
||||
const inicio = performance.now()
|
||||
|
||||
const pintar = (ahora) => {
|
||||
const t = Math.min(1, (ahora - inicio) / DURACION)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// Entra y sale en la misma curva: sin el desvanecido final el corte se
|
||||
// nota como un parpadeo.
|
||||
const opacidadGlobal = t < 0.75 ? 1 : 1 - (t - 0.75) / 0.25
|
||||
|
||||
for (let i = 0; i < puntos.length; i++) {
|
||||
const p = puntos[i]
|
||||
const avance = Math.max(0, Math.min(1, (t - p.demora) / 0.4))
|
||||
if (avance <= 0) continue
|
||||
|
||||
for (let j = i + 1; j < puntos.length; j++) {
|
||||
const q = puntos[j]
|
||||
const dx = p.x - q.x
|
||||
const dy = p.y - q.y
|
||||
const d = Math.sqrt(dx * dx + dy * dy)
|
||||
if (d > 170) continue
|
||||
ctx.strokeStyle = `rgba(142, 176, 47, ${0.16 * (1 - d / 170) * avance * opacidadGlobal})`
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(p.x, p.y)
|
||||
ctx.lineTo(q.x, q.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, p.r * avance, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(142, 176, 47, ${0.5 * avance * opacidadGlobal})`
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
if (t < 1) raf = requestAnimationFrame(pintar)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(pintar)
|
||||
})
|
||||
})
|
||||
|
||||
// Si alguien navega antes de que termine, no queda un rAF girando ni un
|
||||
// timeout tocando un componente que ya no existe.
|
||||
onBeforeUnmount(() => {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
if (apagar) clearTimeout(apagar)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- pointer-events-none: es decorativo y no puede robarle un click a nada. -->
|
||||
<canvas
|
||||
v-if="visible"
|
||||
ref="lienzo"
|
||||
class="fixed inset-0 w-full h-full pointer-events-none z-50"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
</template>
|
||||
@@ -29,6 +29,28 @@
|
||||
color: rgb(var(--texto));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Retícula de plano técnico, muy tenue y ESTÁTICA. Da profundidad sin pedir
|
||||
atención ni gastar un canvas corriendo todo el día. Se desvanece hacia
|
||||
abajo para no competir con las tablas y los formularios, que es donde de
|
||||
verdad se trabaja. */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgb(var(--borde) / 0.5) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(var(--borde) / 0.5) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
-webkit-mask-image: radial-gradient(ellipse 90% 55% at 50% 0%, #000 10%, transparent 70%);
|
||||
mask-image: radial-gradient(ellipse 90% 55% at 50% 0%, #000 10%, transparent 70%);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* El contenido va por encima de la retícula. */
|
||||
#app { position: relative; z-index: 1; }
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -99,3 +121,25 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Un punto que late para lo que está trabajando de verdad: una fuente en
|
||||
"procesando" con la etiqueta quieta no dice si sigue avanzando o si se
|
||||
colgó. Se usa sólo donde hay trabajo real en curso, nunca de adorno. */
|
||||
.latido {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
animation: latido 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes latido {
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.latido { animation: none; opacity: 0.8; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
import { apiUmind, contexto } from '../lib/contexto.js'
|
||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||
import UiMascota from '../components/ui/UiMascota.vue'
|
||||
|
||||
const props = defineProps({
|
||||
tenantId: { type: String, required: true },
|
||||
@@ -594,12 +595,15 @@ watch(
|
||||
<div>
|
||||
<div class="text-sm text-texto">{{ d.origen }}</div>
|
||||
<div class="text-xs text-tenue mt-0.5">
|
||||
<span class="px-1.5 py-0.5 rounded" :class="estadoColor(d.estado)">{{ d.estado }}</span>
|
||||
<span class="px-1.5 py-0.5 rounded inline-flex items-center gap-1" :class="estadoColor(d.estado)">
|
||||
<span v-if="d.estado === 'procesando'" class="latido"></span>
|
||||
{{ d.estado }}
|
||||
</span>
|
||||
<span v-if="d.tipo"> · {{ { url: 'sitio', archivo: 'archivo', texto: 'nota' }[d.tipo] || d.tipo }}</span>
|
||||
<span v-if="d.total_chunks"> · {{ d.total_chunks }} fragmentos</span>
|
||||
<!-- La antigüedad, en rojo cuando pasó de dos meses: es lo único
|
||||
que delata que el agente contesta con información vieja. -->
|
||||
<span :class="estaVieja(d) ? 'text-amber-600 dark:text-amber-400 font-medium' : ''">
|
||||
<span v-if="d.procesado_at" :class="estaVieja(d) ? 'text-amber-600 dark:text-amber-400 font-medium' : ''">
|
||||
· leído {{ antiguedad(d.procesado_at) }}
|
||||
</span>
|
||||
<span v-if="d.auto_actualizar"> · se actualiza sola</span>
|
||||
@@ -948,7 +952,10 @@ watch(
|
||||
>
|
||||
{{ m.content }}
|
||||
</div>
|
||||
<p v-if="chatEnviando" class="text-xs text-tenue">Pensando...</p>
|
||||
<div v-if="chatEnviando" class="flex items-center gap-2">
|
||||
<UiMascota estado="pensando" :tam="30" class="text-brand" />
|
||||
<span class="text-xs text-tenue">Pensando…</span>
|
||||
</div>
|
||||
</div>
|
||||
<form class="flex gap-2" @submit.prevent="enviarChatPrueba">
|
||||
<input v-model="chatInput" placeholder="Escribí un mensaje de prueba..." class="flex-1 input" />
|
||||
|
||||
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
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
|
||||
<title>uMind Studio</title>
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-DN4RTleh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-Ds3rxNrq.css">
|
||||
<script type="module" crossorigin src="/orchestrator/assets/index-DwOw7la-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-0jLV4t_y.css">
|
||||
</head>
|
||||
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
|
||||
que son los que cambian con el tema. Una utilidad acá le ganaba a la
|
||||
|
||||
Reference in New Issue
Block a user