Compare commits

...
40 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Opus 5 be01d95631 feat(studio): la puesta en marcha guiada y una bandeja de conversaciones que informa
Dos mitades de la misma experiencia: el primer día y todos los días después.

El primer día. Un agente recién creado mostraba tres avisos ámbar sueltos —"no
atiende", "no sabe nada"— sin ningún orden: el dueño sabía que faltaban cosas
pero no cuál iba primero. Ahora hay una guía de tres pasos (contale de tu
negocio → hacele una pregunta → ponelo a atender) donde solo el siguiente paso
pendiente se resalta, porque una guía donde todo grita a la vez no guía nada.
Mientras falte algo, la guía reemplaza al estado — una sola voz por etapa; con
los pasos hechos desaparece para siempre y el estado operativo toma su lugar.

Todos los días. La bandeja de conversaciones mostraba el último mensaje de cada
sesión —casi siempre la respuesta del bot, "¡Hola! ¿En qué te ayudo?" cincuenta
veces— y el session_id crudo (tg:8812). Ahora cada conversación muestra lo que
el visitante preguntó al abrir, que es el dato que le importa al dueño: qué le
preguntan de verdad a su negocio. Con canal deducido del prefijo de la sesión
(WhatsApp, Telegram, Tu web, Prueba), cantidad de mensajes, y fecha relativa.

Las pruebas propias se separan detrás de un filtro: mezcladas inflan la lista y
el dueño no distingue cuáles son clientes reales. El contador del estado cuenta
solo las reales, por lo mismo — si arriba dice 4 y la lista muestra 3, el
número parece roto.

El hilo muestra la hora de cada mensaje, y el backend gana una consulta que
agrupa por sesión con el primer mensaje del visitante en vez de mandar filas
crudas; la vieja quedó sin usos y se fue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:40:39 -05:00
Lizandro GuarnizoandClaude Opus 5 22158a10a7 feat(studio): rediseño de la pantalla del agente — estado arriba, tres zonas, probar al lado
La pantalla estaba organizada por tabla de base de datos: siete pestañas que
eran nuestras siete entidades —documentos, herramientas, canales, conexiones,
eventos— y que se desbordaban a lo ancho. Un dueño de negocio nunca piensa "voy
a Conexiones": piensa "¿por qué contestó mal?" o "quiero que sepa mis precios
nuevos".

Tres cambios.

Un estado arriba que responde "¿está bien mi asistente?" sin un clic:
"Atendiendo en WhatsApp · Sabe de 2 cosas · 1 conversación", y en ámbar cuando
algo falta — "No está atendiendo en ningún lado", "Todavía no sabe nada de tu
negocio". Antes eso había que deducirlo entrando pestaña por pestaña. Cada
señal es un botón que lleva a donde se arregla, y si el destino está en
Avanzado, lo despliega.

Tres zonas en lugar de siete pestañas: Conversaciones, Lo que sabe, Dónde
atiende. Herramientas, Cuentas conectadas y Problemas pasan a "Avanzado",
plegado. No se sacan —hacen falta— pero dejan de competir todos los días con lo
que se mira todos los días.

Y el conocimiento con la prueba lado a lado. El bucle real es leer lo que sabe
→ probar → corregir → probar de nuevo; en dos pestañas separadas eso eran seis
clics por corrección. Desde 1024px van en dos columnas, con el chat fijo al
hacer scroll; abajo de eso se apilan, que es el orden en que igual se trabaja.

El vocabulario deja de ser el nuestro: "Base de conocimiento" es "Lo que sabe",
"Auditoría" es "Problemas", "Conexiones" es "Cuentas conectadas". Y la site_key
sale del encabezado: se usa una vez, al instalar el widget, y vive donde se
instala.

No se tocó ni el color ni la tipografía ni Umi. Esto es estructura, no pintura:
cambiando las dos cosas a la vez no se sabría cuál mejoró qué.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:11:53 -05:00
Lizandro GuarnizoandClaude Opus 5 c78fcce89b refactor(studio): partir la pantalla del agente en un componente por pestaña
AgenteDetail tenía 1026 líneas con las siete pestañas adentro: el estado de
todas mezclado en un solo <script setup>, y cualquier cambio en una obligaba a
leer las otras seis para saber qué se rompía. Reorganizarla en ese estado sería
trabajar a ciegas.

Queda en 514 líneas —el encabezado, las pestañas y la carga de datos— más un
componente por zona: conocimiento sigue adentro por ahora, y salen auditoría,
conexiones, chat, conversaciones, herramientas y canales.

El padre sigue siendo dueño de los datos y cada pestaña emite "recargar" en vez
de tener su propia copia: con copias por pestaña, ir y volver entre dos mostraba
estados distintos de lo mismo.

Sin un solo cambio visible, y verificado como tal: se capturaron las siete
pestañas antes de empezar y se compararon píxel a píxel después de cada
extracción. Las siete dan idénticas — la única diferencia que reporta el
comparador está por debajo del umbral y cae exactamente sobre el punto que late
en una fuente "procesando", o sea la animación fotografiada en otro instante.

Este commit no cambia nada para el usuario. Es la base para poder reorganizar
la pantalla sin romperla.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:05:09 -05:00
Lizandro GuarnizoandClaude Opus 5 ee9bb2cfb7 feat(studio): un solo set de iconos en vez de emojis mezclados con símbolos
En la misma pantalla convivían dos lenguajes: emojis a color (🌙 📊 🧠 ✍️ 📄
🌐 🔁) y símbolos tipográficos monocromos (✎ ✕ ← ↻ ⚠ ⬇ ✓). Eso se lee como
descuido, y encima los emojis los dibuja el sistema operativo — el mismo panel
se ve distinto en Mac, en Windows y en Android, sin que podamos hacer nada.

Ahora hay un set propio de iconos SVG de trazo: mismo grosor de línea, misma
caja, y heredan currentColor, así que toman el color del texto que los rodea y
funcionan igual en tema claro y oscuro. Al lado uno de otro tienen el mismo
peso visual, que es lo que hacía falta.

El emoji suelto de la pantalla de inicio pasa a ser Umi, que ya es el personaje
del producto.

Un detalle de implementación que costó dos intentos: los iconos NO pueden
declararse como cadenas de HTML para v-html. Dentro de un <svg>, v-html parsea
como HTML y los <path> quedan sin el namespace de SVG: aparecen en el DOM pero
no dibujan nada, así que la primera versión se veía perfecta en el código y en
blanco en pantalla. Van declarados como pares [etiqueta, atributos] para que
Vue los cree con el namespace correcto.

Y AgenteDetail usaba cinco iconos sin importar el componente. Verificado ahora
sobre todos los archivos: cada componente usado está importado donde se usa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:45:43 -05:00
Lizandro GuarnizoandClaude Opus 5 002b75e71b 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>
2026-08-21 20:44:40 -05:00
Lizandro GuarnizoandClaude Opus 5 b97814d62d fix(soporte): un envío de correo sin base de datos tumbaba el proceso entero
soporteSendMail leía la configuración SMTP sin verificar que hubiera base. Con
la conexión en nil eso es un desreferenciado de puntero, y como el envío corre
en su propia goroutine no hay recover que lo contenga: se lleva puesto el
proceso.

En producción no se veía porque siempre hay base. Lo que sí bloqueaba era la
suite de pkg/services, que no se podía correr entera desde hacía tiempo —
cualquier test que tocara el auto-respuesta de soporte hacía explotar toda la
corrida y tapaba el resultado del resto.

Ahora devuelve un error en vez de reventar, y los 7 paquetes con tests del
proyecto pasan en verde.

(utils/xopen tiene 2 tests que fallan: es una dependencia de terceros incluida
en el repo desde el commit inicial y no la toca nada de esto.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:43:24 -05:00
Lizandro GuarnizoandClaude Opus 5 a8cc5c6289 fix(umind): la respuesta genérica no quedaba en la conversación, y eso perpetuaba el error
Cuando el agente caía en "dame un poco más de detalle", esa respuesta no se
guardaba. En Conversaciones quedaban las preguntas del visitante una tras otra
sin ninguna respuesta, así que el dueño no tenía forma de enterarse de que su
agente estaba fallando — justo la pantalla donde debería verlo.

Lo grave es lo otro: ese historial se le reenvía al modelo en cada turno. Un
modelo que ve cuatro preguntas seguidas y ninguna respuesta lee una
conversación rota, se confunde y vuelve a fallar. El primer error se
perpetuaba solo, y por eso una vez que empezaba ya no salía más.

Ahora se guarda siempre lo que el visitante recibió, sea la respuesta buena o
la genérica.

Las sesiones que ya quedaron con el historial roto se normalizan solas: la
ventana es de 6 mensajes, así que después de un par de intercambios buenos los
huecos salen de contexto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:41:13 -05:00
Lizandro GuarnizoandClaude Opus 5 cdfbff605e fix(umind): el agente se quedaba sin rondas buscando y nunca llegaba a responder
Los eventos de producción mostraban "se agotaron las rondas" tres veces
seguidas en un agente que respondía bien en el chat de prueba. La diferencia no
era el widget: era la conversación. Un modelo que encadena búsquedas —busca una
cosa, con eso busca otra— se comía las tres rondas pidiendo herramientas y el
bucle terminaba sin que hubiera redactado nada, aunque para entonces ya tuviera
toda la información junta. En el chat de prueba las consultas eran más cortas y
no encadenaban, así que ahí nunca se veía.

Ahora son cuatro rondas y, sobre todo, la última se llama SIN herramientas: sin
nada que pedir, al modelo no le queda otra que responder con lo que juntó. Que
es exactamente lo que se quiere en el último turno.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:32:13 -05:00
Lizandro GuarnizoandClaude Opus 5 97e66b82e3 fix(umind): el aviso de tope se repetía en cada despliegue y el cliente no se enteraba
Qué pasa cuando un cliente supera el tope de consumo de su plan: el servicio
sigue funcionando y el excedente se factura en el próximo ciclo. Es la decisión
de diseño y no cambia — cortarle el asistente a un negocio en medio de una
conversación con un cliente suyo es peor que la factura.

Lo que sí estaba mal es cómo se avisaba.

El control de "a este ya le avisé" vivía en memoria, con un ponytail: que
asumía reinicios raros. Con despliegue automático en cada push, ese flag se
borra varias veces por día: el mismo cliente pasado de tope recibía el aviso de
nuevo en el siguiente mensaje, y otra vez, y otra. Ahora se marca en la base,
con un UPDATE condicionado que además lo hace atómico — dos mensajes que crucen
el tope a la vez, o dos réplicas del proceso, avisan una sola vez.

Y el aviso iba sólo al Telegram del staff. El dueño del negocio, que es el que
va a recibir la factura con el excedente, no se enteraba por ningún lado: tenía
que entrar al panel a mirar. Ahora le llega un correo que dice cuánto lleva
consumido, cuánto incluye su plan y —lo más importante— que su asistente sigue
funcionando con normalidad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:20:54 -05:00
Lizandro GuarnizoandClaude Opus 5 9dba93e7f4 feat(widget): mostrar que el asistente está escribiendo
Entre que el visitante manda y el agente contesta pasan varios segundos —más si
tiene que buscar en la base de conocimiento— y el widget se quedaba mudo. Sin
señal de vida, la reacción normal es pensar que se colgó y volver a mandar.

Ahora aparecen los tres puntos que cualquiera reconoce de WhatsApp, y el campo
se bloquea mientras responde: mandar tres mensajes seguidos desordena el hilo y
multiplica el consumo sin que nadie lo pida.

La limpieza va en un then final y no en el de éxito: si quedara ahí, un error
de red dejaría los puntitos animándose para siempre y el campo bloqueado, que
es peor que no haber puesto nada. Probado con la respuesta normal y con un
fetch que falla.

El indicador se inserta siempre al final, así que un mensaje que llegue mientras
tanto no lo deja en el medio del hilo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:18:09 -05:00
Lizandro GuarnizoandClaude Opus 5 44686d409a fix(umind): el agente descartaba la respuesta que ya había escrito
Cuando se agotan las tres rondas de herramientas sin una respuesta final, el
visitante recibe "dame un poco más de detalle". Pero un modelo puede mandar
texto Y pedir una herramienta en el mismo turno, y ese texto se tiraba: si
después se agotaban las rondas, la persona recibía el mensaje genérico aunque
el agente ya le hubiera contestado bien.

Ahora se guarda lo último que escribió y se usa antes de caer en el genérico.

Y el evento que quedaba en Auditoría decía "se agotaron las rondas" sin nada
más, que no alcanza para saber dónde mirar. Ahora registra qué herramientas
pidió en orden, y cada vez que una devuelve error queda su propio evento con la
respuesta cruda. Una herramienta que falla y un modelo que la reintenta es la
forma más común de agotar las rondas, y hasta ahora había que deducirlo.

Esto no arregla la causa de un agente puntual que responde el genérico a todo
—esa sale de los eventos que ahora sí se registran— pero deja de esconder
respuestas buenas y dice dónde buscar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:19:17 -05:00
Lizandro GuarnizoandClaude Opus 5 b125ceef95 fix(umind): la config de IA de un cliente podía atender tareas del sistema
Auditando el aislamiento apareció el agujero al revés del que se buscaba: no
un cliente leyendo datos de otro, sino la cuenta de IA de un cliente pagando
trabajo nuestro.

GetAiConfigForService recorre las configs activas y devuelve la primera sin
módulo asignado. Las configs de cliente no llevan módulo — ninguna lo lleva, es
parte del diseño — así que caían justo en ese fallback. Con un cliente que
hubiera conectado su cuenta, su clave terminaba clasificando correos de
soporte, importando plantillas o atendiendo la vCard. Ninguno de los dos se
enteraba: la respuesta llegaba igual y la factura le llegaba a él.

Todos los resolvedores globales filtran ahora tenant_id IS NULL. Un test lo
verifica sobre el código de cada uno, porque son consultas a base y acá no hay
una.

El de embeddings además no podía ser de cliente por otra razón: los vectores de
todos los agentes tienen que salir del mismo modelo o la similitud coseno entre
ellos no significa nada. Un cliente con su propio modelo de embeddings rompía
su propia búsqueda sin un solo error visible.

Del alcance entre clientes, que era lo que se auditaba: los 39 handlers de
uMind validan, y el CRUD de espacios y planes ni siquiera se monta en las rutas
del portal. Lo que faltaba era prueba: UmindScopeDe distingue "staff" de
"cliente sin espacios" por nil contra slice vacío, y esa diferencia no tenía
un solo test. Ahora la cubre uno que además falla si se invierte el fail-closed
de una ruta sin scope — probado inyectando las dos fugas.

Y dos cosas que quedaban colgando:

En /app/ai-config toda config de cliente se mostraba como "Global", que es
justo lo que no es. Ahora dice de qué espacio es, por nombre.

El consumo de una cuenta propia se registraba con el costo del plan. Se sigue
midiendo —el cliente quiere ver cuánto usa su asistente— pero con costo cero y
marcado como cuenta propia: cobrarlo también sería cobrar dos veces lo mismo.
En la pantalla de consumo aparece "va por tu cuenta de IA" en vez de un "$0"
que parecería un error. El OCR y la transcripción siguen costando: son
servicios nuestros, los use quien los use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:15:28 -05:00
Lizandro GuarnizoandClaude Opus 5 926bbde9fb feat(umind): que el cliente conecte su propia cuenta de IA
El modelo soportaba una config de IA por espacio desde hace varias fases, pero
no había forma de cargarla: ni el cliente desde el portal, ni el staff por él.
Quedaba como una columna que solo se podía llenar tocando la base a mano.

Ahora hay una pantalla — "Tu IA", desde la lista de agentes — donde se conecta
una cuenta de OpenAI, Anthropic, Gemini, Groq, DeepSeek, Qwen u Ollama. Con el
botón "Probar", que manda una consulta real: una clave vencida se descubre ahí
y no cuando un cliente escribe y no le contestan.

El aislamiento es la parte delicada, y va en tres capas:

Las configs globales son del staff. Un cliente puede usarlas —le aparecen en el
selector— pero no editarlas ni borrarlas; el acceso corta antes de mirar
permisos, así que ni siquiera se le confirma que existen. Sin eso, cualquiera
podría cambiarle el proveedor de IA a todos los demás o dejarlos sin servicio.

El tenant sale del alcance ya validado, nunca del body. Si viniera del cuerpo
de la petición, bastaría con cambiar el número para colgarle una config a otro
cliente, y con ella una clave que no es suya.

El tenant no se puede mover en una edición, por lo mismo al revés: sería
regalarle la propia.

La clave se guarda cifrada y no vuelve nunca al navegador — ni al dueño. Solo
sus últimos cuatro caracteres, que alcanzan para reconocer cuál cargó.

Borrar está bloqueado si hay agentes usándola: si no, quedarían apuntando a
algo inexistente y cayendo al proveedor global sin que nadie se entere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:08:43 -05:00
Lizandro GuarnizoandClaude Opus 5 195590f5e4 fix(umind): faltaba el botón para editar las notas del conocimiento
Las plantillas de rubro dejan las notas con valores entre corchetes y la propia
pantalla dice "abrilas y reemplazalas por tus datos" — pero no había con qué.
El endpoint para editarlas existía desde que se agregaron las fuentes de texto;
lo que nunca se agregó fue el botón, así que el flujo que el producto promete
no se podía completar. Se elegía la plantilla de restaurante y quedaban cuatro
notas diciendo "[12:00]" sin forma de arreglarlas salvo borrarlas y escribirlas
de nuevo.

Ahora cada nota tiene "Editar": abre el contenido, se corrige y al guardar se
rehacen los fragmentos, así que el agente pasa a contestar con lo nuevo en el
momento.

También se puede editar el texto que se le extrajo a un archivo: si el OCR de
un PDF salió torcido, corregirlo a mano es tan válido como escribir la nota, y
es el mismo código. Las URLs no — su contenido se rehace crawleando y cualquier
corrección se perdería en la próxima actualización.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:03:58 -05:00
Lizandro GuarnizoandClaude Opus 5 3ceb61c035 feat(umind): Umi, la mascota, y un solo vocabulario en toda la interfaz
La mascota. Umi sale del logo, no es un dibujo aparte: su cuerpo es la "u" del
monograma y el punto del logo pasa a ser su antena, así que símbolo y personaje
son la misma forma vista dos veces. Por eso puede estar al lado del logo sin
competirle.

Tiene estados, y no son decoración: cada pantalla vacía dice algo distinto y la
cara lo dice antes que el texto. Dormida cuando todavía no pasó nada, buscando
cuando falta cargarle información, contenta cuando no hay errores, alerta
cuando algo se rompió. Los ojos toman el color de la superficie de atrás en vez
de ser blancos, así se apoya sobre cualquier tarjeta y en modo oscuro no quedan
dos puntos flotando.

Las pantallas vacías. Había una buena y ocho que decían "Sin canales
configurados." y nada más — el usuario quedaba resolviendo solo qué significa
eso y qué hacer. Ahora las ocho explican qué va ahí y por qué importa: "No está
atendiendo en ningún lado", "Todavía no sabe nada de tu negocio".

El vocabulario. La misma cosa tenía dos nombres según la pantalla: tenant y
espacio, tool y herramienta. Un producto que se llama distinto a sí mismo en
cada lugar se siente como varios productos pegados. Queda: espacio, agente,
herramienta, fuente. Y "al AI" pasa a "a la IA", que además es el género que ya
usaba el resto del panel.

Y la barra de scroll de las pestañas, que se veía gris y gruesa sobre el
contenido: en escritorio las siete entran holgadas y no hacía falta, en móvil
siguen deslizándose pero sin la barra encima.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:19:01 -05:00
Lizandro GuarnizoandClaude Opus 5 5086087e1b feat(umind): duplicar un agente para usarlo como plantilla del siguiente
El catálogo de rubros da un arranque genérico, pero el mejor punto de partida
para el segundo restaurante es el primero — el que ya tiene el conocimiento
real, el tono ajustado y las herramientas andando. Ahora se puede copiar: se
lleva conocimiento, tono, bienvenida, color, config de IA y herramientas.

Tres cosas no se copian, y es a propósito:

Los canales. Llevan las credenciales de una cuenta concreta de WhatsApp o
Telegram; copiarlas haría que dos agentes contesten por el mismo número.

Las claves de las herramientas. Son secretos de un tercero, atados a una
cuenta. La copia llega sin ellas y, si la herramienta las necesitaba, llega
desactivada — para que la falta se note al configurarla y no cuando un cliente
recibe un error.

La site_key. Es la identidad pública del widget y tiene índice único:
compartirla sería servir dos agentes distintos bajo el mismo nombre.

El conocimiento se rehace desde cero en vez de copiar los vectores. Es más
lento, pero los chunks viejos pueden venir de otro modelo de embeddings, y
mezclar vectores de modelos distintos rompe la comparación por similitud —
la búsqueda devolvería cualquier cosa.

Permisos: se verifica el acceso al agente de origen y también al tenant
destino. Copiar es crear, y crear en un tenant ajeno tampoco corresponde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:28:15 -05:00
Lizandro GuarnizoandClaude Opus 5 af71b66bb8 feat(umind): logo propio, plantillas de agente por rubro y página promocional
Logo. Hasta ahora no había: era el texto "uM" en un cuadrado, con tamaño y
esquinas distintas en cada pantalla. Ahora hay un mark — la "u" del nombre con
el punto de pulso integrado — probado a 16px, 20px y 32px sobre claro y oscuro,
que es donde se cae la mayoría de los logos. Reemplaza el texto en el portal,
el header, el sidebar del Studio y la promo, y va también de favicon.

Plantillas por rubro. Un agente recién creado no sabía nada, y escribir el
conocimiento desde cero frente a un campo en blanco es donde la mayoría
abandona. Al crear uno se puede elegir un punto de partida —tienda,
restaurante, consultorio, servicios, peluquería o la base genérica— y arranca
con sus notas ya escritas, listas para editar.

Los valores de ejemplo van entre corchetes a propósito, para que se vea que hay
que reemplazarlos: una nota que parezca dato real termina en boca del asistente.
Un test lo verifica nota por nota. Y la plantilla de salud abre con el límite —
el asistente no da consejo médico ni interpreta síntomas — porque es el único
rubro donde una respuesta inventada hace daño de verdad.

El catálogo vive en código, no en base: es contenido editorial que mejoramos
nosotros, no configuración que cada instalación toca por su lado. Las notas se
cargan en segundo plano porque cada una necesita sus embeddings, y hacer
esperar el alta sería castigar justo al que eligió plantilla.

Con las opciones nuevas el formulario pasaba de largo la pantalla y el botón de
guardar quedaba abajo del borde: el modal ahora tiene su propio scroll.

Y la página promocional de uMind queda servida en /umind.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:21:46 -05:00
Lizandro GuarnizoandClaude Opus 5 3ea17b0980 feat(umind): cargar conocimiento de tres formas y que deje de quedar viejo en silencio
Hasta ahora la única forma de darle información a un agente era crawlear una
URL, una vez, para siempre. Tres cosas cambian:

Escribir a mano. Es la fuente más valiosa y la única que no está en ningún
documento: horarios, qué no hacen, la respuesta que dan quince veces por día.
También es la única que el dueño puede corregir en el momento en que ve al
agente contestar mal.

Subir un archivo. La lista de precios suele estar en un PDF, no en la web. Usa
el mismo extractor que los adjuntos de los canales, así que PDF, Word, texto e
imágenes entran sin código nuevo. El texto se extrae con la persona mirando la
pantalla: si el archivo no se puede leer, se dice ahí y no en un log.

Y lo importante: el conocimiento se congelaba el día que se cargaba. Si el
cliente cambiaba los precios en su sitio, el agente seguía dando los viejos con
total seguridad — sin error, sin aviso, nada. Ahora cada fuente muestra de
cuándo es ("leído hace 3 meses", en ámbar pasados dos meses), tiene botón de
actualizar, y las URLs pueden marcarse para releerse solas cada semana (cron a
las 4 AM). Reprocesar reemplaza los fragmentos en vez de sumarlos: si no,
quedaban las dos versiones compitiendo en la búsqueda y podía ganar la vieja.

De paso, el troceado dejaba fragmentos que arrancaban a mitad de palabra
("alabra…") porque el solape no se alineaba a un espacio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:45:41 -05:00
Lizandro GuarnizoandClaude Opus 5 3ee83c8534 fix(studio): el cliente sin uMind entraba a un callejón sin salida
Si un cliente llegaba a /portal/studio sin ningún espacio asignado —por la URL
directa o un enlace viejo— la pantalla le decía "elegí tu espacio de la
izquierda" y "adentro vas a poder crear tus agentes". La izquierda estaba
vacía y crear no puede.

Ahora ve qué es uMind, un botón para pedir que se lo activen y la vuelta al
portal. Es el mismo mensaje que ya muestra el dashboard cuando no lo tiene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:30:57 -05:00
Lizandro GuarnizoandClaude Opus 5 86cdb2d368 feat(plantillas): ver cómo queda la plantilla mientras se edita
Se editaba HTML a ciegas: había que guardar y generar un documento real para
saber si estaba bien. Ahora el modal tiene un panel de vista previa al lado del
editor, que se actualiza mientras se escribe y se abre solo al editar una
plantilla o al importar una con IA.

Se ejecuta la plantilla de verdad contra datos de ejemplo, no se reemplaza
texto: es la única forma de que {{range .Items}} y los campos anidados se vean
como van a salir, y de que un error de sintaxis aparezca mientras se edita en
vez de al generar el PDF. Los datos de ejemplo salen de DatosBaseDocumento,
igual que en producción, para que la vista previa no muestre una cosa y el
documento otra.

El iframe va en sandbox sin scripts ni same-origin: el HTML lo escribe un
admin, pero no tiene por qué correr con los permisos del panel.

De paso, la ayuda de variables ofrecía {{.Cliente.Nit}}, que no existe en el
modelo — el campo es Documento.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:28:06 -05:00
Lizandro GuarnizoandClaude Opus 5 5f95e67595 fix(api-v2): que el fallo de /vcard/ia quede en el log y distinga de quién es el problema
La integración muestra solo "error code: 502" y el motivo no quedaba escrito en
ningún lado. Ahora se loguea.

Y el estado separa los dos casos: 503 si falta configurar la IA de este lado,
502 si el que falló fue el proveedor. Con el mismo código para ambos no se sabe
a quién mirar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:19:15 -05:00
Lizandro GuarnizoandClaude Opus 5 f5ffed7a13 fix(ai-config): editar cualquier config dejaba al agente sin cerebro
es_agente_bot no se podía marcar desde ninguna pantalla —el formulario nunca
mandaba el campo— y el update lo escribía igual con el valor cero. O sea que
guardar cualquier config desde /app/ai-config apagaba el cerebro del bot de
Telegram y del chat del panel, y no había forma de volver a prenderlo salvo
tocando la base.

- El update solo escribe los campos que vinieron en el body.
- El formulario tiene la casilla y el selector de bot de Telegram.
- Marcar una desmarca la anterior: GetAgenteBotAiConfig hace First(), así que
  con dos marcadas ganaba la que estuviera primero en la tabla.

Y el otro comportamiento raro: cuando ningún módulo coincidía, se usaba
"cualquier config activa". Eso podía elegir la de embeddings o la de Whisper,
que no conversan — el error que llegaba era del proveedor y no se parecía en
nada a la causa. Ahora esas quedan excluidas del comodín y, si no queda
ninguna usable, el error dice qué módulo asignar y dónde.

De paso: la etiqueta "IA / vCard" mentía (ese módulo alimenta además soporte,
el chat del panel y las plantillas), el comentario de is_active decía "solo uno
activo a la vez" cuando hace falta uno por módulo, y GetActiveAiConfig no la
usaba nadie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:16:15 -05:00
Lizandro GuarnizoandClaude Opus 5 6a3a8c9248 fix(api-keys): detrás de Cloudflare la IP que se comparaba era la del edge
El 403 reportaba ip_vista=104.22.14.222, que es un edge de Cloudflare: el
tráfico entra por CF y recién ahí por el proxy del hosting, así que la última
entrada de X-Forwarded-For es el edge y no quien llama.

Se usa CF-Connecting-IP cuando está: la escribe Cloudflare con la IP real y
sobrescribe la que mande el cliente. Sin Cloudflare sigue valiendo la última
entrada del XFF, como estaba.

Queda anotado que todo esto vale mientras el tráfico entre por el proxy: quien
pueda pegarle al origen directo puede falsear los dos encabezados, y eso se
cierra en la red, no en esta función.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:09:50 -05:00
Lizandro GuarnizoandClaude Opus 5 dcffab4311 feat(plantillas): poder elegir qué modelo usa "Leer con IA"
Convertir un documento entero en plantilla pide un modelo más capaz que el
resto de las tareas, pero salía por el módulo "IA / vCard", compartido con la
integración de vCard: subir el modelo ahí es subírselo a todo.

Ahora hay un módulo "Plantillas de documento" en /app/ai-config. Si alguna
config activa lo declara, la importación usa esa; si no, sigue saliendo por
"ia" exactamente como hasta ahora. Nadie tiene que configurar nada para que
siga funcionando.

La pantalla de plantillas aclara además que esto no usa un agente de uMind y
adónde ir a cambiar el modelo, que era la duda.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:08:17 -05:00
Lizandro GuarnizoandClaude Opus 5 c75a6d7deb fix(api-keys): la restricción por IP comparaba contra la IP del proxy, no la del cliente
c.IP() detrás del proxy del hosting devuelve la IP interna del contenedor que
reenvía (10.x.x.x). Contra eso, ninguna IP pública cargada en una llave podía
coincidir: toda API key con restricción de IP daba 403, que es exactamente lo
que le pasó a la integración de vCard.

Ahora se lee X-Forwarded-For, y se toma la ÚLTIMA entrada, no la primera: con
un proxy adelante esa es la que escribió el proxy. Si quien llama manda su
propio X-Forwarded-For, el proxy le agrega la IP real al final — quedarse con
la primera sería dejar que cada uno declare su IP y la restricción no valdría
nada. El test fija ese caso.

Mismo arreglo en la autenticación de Pagos Externos, que comparaba igual.

El 403 ahora dice qué IP se vio y cuál está permitida: sin eso, del otro lado
se prueba a ciegas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:02:45 -05:00
Lizandro GuarnizoandClaude Opus 5 dda5898c0d fix(api-keys): no se podía asignar el scope vcard
La pantalla lo ofrecía y las rutas de /api/v2/vcard lo exigen, pero la lista de
scopes válidos del backend nunca lo incluyó: crear la llave devolvía "scope
inválido: vcard" y la integración de vCard no tenía forma de autenticarse.

La lista estaba escrita dos veces —una en Go y otra en el HTML— y se
desincronizaron. Ahora la vista la pide al backend, así que no puede volver a
ofrecer algo que el backend rechace, y un test fija que todo scope exigido por
las rutas se pueda asignar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:49:07 -05:00
Lizandro GuarnizoandClaude Opus 5 4135850e5b feat(api-v2): endpoint de generación de texto para la integración de VCard
El sistema de vCard necesitaba usar la IA configurada en /app/ai-config y el
único camino era /api/v1/ia/ollama-generate, que autentica con cookie de
sesión: incómodo para una llamada entre servidores y la razón de los 401.

Ahora hay POST /api/v2/vcard/ia, bajo el mismo scope y la misma API key
(Bearer + IP) que ya usa esa integración para los QR y los VCF. Manda prompt,
recibe texto.

La clave del proveedor no sale del servidor. La alternativa —devolverle la
configuración con la api_key adentro, como hace el Landing Generator— reparte
la credencial de OpenAI/Anthropic a cada aplicación que la quiera usar.

El módulo va fijo en "ia": si lo eligiera quien llama, una llave con scope
vcard podría usar la configuración del agente o la del Landing Generator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:44:35 -05:00
Lizandro GuarnizoandClaude Opus 5 5afbd9e375 fix(soporte): aviso repetido en Telegram y el acuse al cliente que no salía
Telegram: los avisos se mandaban a todas las configuraciones activas sin mirar
a dónde apuntan. Con el mismo chat cargado en dos filas, cada aviso llegaba dos
veces. Ahora se manda una vez por destino (bot + chat); los chats distintos
siguen recibiendo todos.

Correo al remitente: el camino STARTTLS abría una conexión, hacía StartTLS,
autenticaba… y la descartaba para llamar a smtp.SendMail, que abre otra. La
primera quedaba colgada y el envío real salía por una conexión distinta, que
podía no estar autenticada. Reescrito: una sola conexión, asegurada según la
configuración, y cierre con QUIT.

Y como el acuse se manda en segundo plano, su error moría en el log. Ahora hay
un botón "Enviar correo de prueba" que usa exactamente el mismo camino y
devuelve el error del servidor en pantalla, y el último fallo del acuse real
aparece en el resultado de "Revisar buzón ahora".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:13:11 -05:00
Lizandro GuarnizoandClaude Opus 5 17ed57cb56 fix(vcard): decir por qué falla la subida a OSS, y no armar la clave con texto crudo del cliente
El sistema que consume /v2/vcard/qr-url solo recibía "Error subiendo archivo a
OSS": el motivo quedaba en nuestro log y del otro lado no había nada que hacer
con eso. Ahora la respuesta incluye el error real del proveedor —es una API
entre servidores y el que llama ya conoce la config de OSS que mandó— y el log
dice además qué clave se intentó subir.

La clave del objeto se armaba con el nombre tal como venía en el body. Un
nombre con "/" o con caracteres que OSS no acepta producía justamente ese
error genérico. Ahora se limpia.

El mismo nombre crudo se usaba para el archivo temporal (/tmp/<nombre>.webp),
o sea escritura de archivos en una ruta que elegía quien llamaba. Se fue
entero: los tres endpoints suben desde memoria con UploadFromReader, que
además saca el paso a disco y su limpieza.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:04:34 -05:00
Lizandro GuarnizoandClaude Opus 5 4674413257 fix(soporte): el filtro se estaba comiendo correos nuevos, y nadie se enteraba
Una respuesta a un hilo existente saltea el clasificador; un correo nuevo no.
Por eso el sistema parecía funcionar solo con las respuestas: los correos
nuevos que el modelo consideraba "no soporte" desaparecían sin ticket, sin
acuse al cliente y sin más rastro que un renglón de log que nadie mira.

Dos cambios:

- El filtro solo opina sobre desconocidos. Si el remitente es un cliente o un
  usuario del portal, siempre se abre el ticket. Para eso la resolución del
  remitente pasa a correr antes del filtro. A un cliente registrado no se le
  descarta el correo por lo que diga un modelo.

- Lo descartado se ve. "Revisar buzón ahora" informa cuántos dejó afuera el
  filtro, con asunto, remitente y motivo. Guardado en memoria: es diagnóstico
  de hace un rato, no algo que valga una tabla.

Además, si la respuesta automática está apagada, el log lo dice al crear el
ticket — era la otra explicación posible de "no me contestó" y no se distinguía.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:56:32 -05:00
Lizandro GuarnizoandClaude Opus 5 1b599606f7 feat(soporte): botón de borrador que redacta con la base de conocimiento
En cada ticket, " Borrador" propone una respuesta usando los fragmentos más
parecidos de la base de conocimiento del agente de uMind que se elija en la
configuración de soporte, más el hilo de la conversación.

El borrador cae en el cuadro de respuesta y no se manda: lo revisa una persona
y aprieta enviar. Contestarle a un cliente con lo que dijo un modelo, sin que
nadie lo lea, es la forma más rápida de perderlo.

Sin agente configurado el borrador se arma igual, solo con la conversación —
peor, pero mejor que un error. El prompt le prohíbe inventar precios, plazos o
pasos que no estén en la documentación, y le pide decir qué falta preguntar
cuando no alcanza para resolver.

El cuadro de respuesta pasa a textarea: un borrador de varios renglones no
entraba en un input de una línea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:45:38 -05:00
Lizandro GuarnizoandClaude Opus 5 bfcc320b7c feat(soporte): el ticket de correo ya sabe de quién es, y llega con prioridad
Un ticket que entraba por correo guardaba la dirección como texto suelto: no se
podía cruzar con nada, el cliente no lo veía en su portal y no había forma de
pedir "todos los tickets de Acme". Ahora al crearlo se resuelve el remitente —
primero contra los usuarios del portal, si no contra el correo del cliente— y el
ticket queda atado a quien escribió. El que no matchea queda como contacto
externo, que también es información.

Solo coincidencia exacta de dirección, nunca por dominio: con gmail.com de por
medio, adivinar ata el ticket al cliente equivocado.

Y la llamada de IA que ya clasificaba el correo ahora devuelve también prioridad
y categoría en el mismo JSON: sin costo ni latencia extra. El campo prioridad
existía desde siempre y nadie lo llenaba. La urgencia la define el problema
descrito, no el tono del mensaje — está dicho en el prompt.

En la lista de tickets se ven los tres datos nuevos: cliente (o "externo"),
categoría y la prioridad que ya se mostraba.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:43:28 -05:00
Lizandro GuarnizoandClaude Opus 5 6afad025c2 feat(soporte): leer solo los correos recientes, no todo el buzón
Sin ventana, la primera corrida convierte en tickets todo lo que haya sin leer,
que en una casilla de años es una avalancha. Ahora hay un desplegable de
antigüedad máxima —6h, 12h, 24h, 3 días o todo— y por defecto 12 horas.

El corte se hace en dos pasos porque IMAP no da para más: al servidor se le
pide SINCE con un día de margen (SINCE compara solo la fecha, no la hora) y el
corte fino por hora se aplica contra la fecha real de cada mensaje. Afinar el
SINCE sería perder los correos del borde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:39:00 -05:00
Lizandro GuarnizoandClaude Opus 5 d7c266f111 feat(soporte): filtro con IA para los correos entrantes, y que un correo no pueda abrir dos tickets
Filtro (opcional, apagado por defecto): antes de abrir ticket, la IA lee el
correo y decide si es soporte o ruido —newsletters, notificaciones de
plataformas, facturas de proveedores, spam—. Falla abierto: si la IA no está
configurada o se cae, el ticket se abre igual. Ignorar a un cliente es peor que
tener un ticket de más. Hay un campo de contexto del negocio para los casos
raros de cada uno.

Los correos masivos se descartan por sus propios encabezados (List-Unsubscribe,
Precedence, Auto-Submitted) sin gastar una llamada de IA.

Y el motivo por el que el mismo correo se leía y se contestaba de nuevo: la
deduplicación era solo un SELECT previo, que no sirve si el flag de leído no
llegó a guardarse o si dos instancias leen el buzón a la vez. Ahora hay un
índice único parcial sobre message_id: la base rechaza el segundo, y ese rechazo
se trata como "ya estaba", no como error — sin ticket repetido y sin segunda
auto-respuesta.

Si algún correo no se pudo marcar como leído, "Revisar buzón ahora" lo dice en
vez de dejarlo solo en el log: es la señal de que van a volver a leerse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:36:01 -05:00
Lizandro GuarnizoandClaude Opus 5 5875f9e36b fix(soporte): el buzón IMAP nunca se leía — la búsqueda pedía UIDs a una búsqueda por secuencia
Usé c.Search, que devuelve números de secuencia, y después le pedí los UIDs al
resultado. go-imap devuelve nil ahí, así que la lista salía siempre vacía: el
cron conectaba bien, buscaba bien, y se iba sin leer nada ni escribir un solo
error en el log. Va con UIDSearch.

Con eso se arreglan las dos cosas: no leía y no creaba tickets.

Además:
- Fetch con Peek: el correo queda marcado como leído solo si se llegó a
  procesar. Antes, un fallo a mitad de camino lo perdía para siempre.
- "Probar conexión" ahora informa cuántos mensajes hay en la carpeta y cuántos
  sin leer; "Revisar buzón ahora" informa cuántos encontró y cuántos convirtió
  en ticket, y avisa si la lectura está desactivada.
- Un correo sin asunto abre ticket como "(sin asunto)" en vez de descartarse en
  silencio, y los descartes quedan en el log.

Los tres tests nuevos corren contra un servidor IMAP en memoria: bajar los no
leídos, que marcar como leído los saque de la próxima corrida, y que bajarlos
sin procesarlos no los marque. El primero falla si se vuelve a poner Search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:20:27 -05:00
Lizandro GuarnizoandClaude Opus 5 d1bbb4a508 feat(soporte): avisar por Telegram cuando entra un correo, con resumen si es largo
Los tickets del portal ya avisaban por Telegram; los que entran por correo solo
mandaban un mail al admin, que es justo el canal que nadie mira a tiempo. Ahora
pasan por el mismo despachador de notificaciones, así que se prenden y apagan
desde /app/notif-config como cualquier otro evento — nuevo ticket y respuesta
del cliente por separado.

El cuerpo del aviso es un resumen cuando el correo pasa los 700 caracteres
(hilos reenviados, texto pegado); abajo de eso va tal cual, porque resumir tres
renglones es gastar una llamada de IA para decir lo mismo. Si la IA falla, se
manda recortado. El ticket guarda siempre el texto completo.

DispatchTicketNuevo ahora acepta portalUser nil: un correo no tiene usuario de
portal detrás y no por eso hay que dejar de avisar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:12:36 -05:00
Lizandro GuarnizoandClaude Opus 5 ac47769c27 fix(plantillas): "Leer con IA" fallaba con la config de IA que ya está en producción
Escribí una segunda implementación de "llamar al proveedor" en vez de usar la
que ya existe (callAI, la del bot de Telegram). Esa copia no hablaba Anthropic
—se lo mandaba todo al endpoint de OpenAI— y, si la config tenía la URL base
vacía (lo normal: la vista dice que es opcional), armaba una URL relativa que
ni siquiera es una petición válida. Cualquiera de las dos cosas terminaba en
el 502.

Ahora usa callAI, que despacha por proveedor y completa la URL base. El error
del proveedor queda en el log del servidor y el mensaje llega entero a la vista;
si la respuesta no trae mensaje, la vista muestra el status para distinguir un
fallo de la app de uno del proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:07:03 -05:00
Lizandro GuarnizoandClaude Opus 5 c4b0305112 feat(umind): los agentes también leen archivos, no solo audios e imágenes
Faltaba la tercera pata: audio pasaba por Whisper, imagen por OCR, y un PDF o
un Word adjunto se ignoraba en silencio. Ahora hay un interruptor por canal
—"Leer archivos adjuntos"— y los documentos que llegan por Telegram o WhatsApp
se convierten a texto antes de pasar al agente.

PDF se lee con stdlib cuando el documento es digital (facturas, cotizaciones,
lo exportado por cualquier programa) y cae al OCR si es un escaneo. Word .docx
es un zip con XML adentro; texto plano, CSV y JSON van directo.

El agente recibe el contenido con contexto ("el cliente adjuntó X, y escribió
Y"), no el chorizo pelado: sin eso el modelo contesta como si el cliente
hubiera tipeado una factura.

De paso la importación de plantillas gana PDF, que usa el mismo extractor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 18:50:59 -05:00
Lizandro GuarnizoandClaude Opus 5 e681f41639 feat(plantillas): subir el documento y que la IA lo devuelva como plantilla editable
Hasta ahora crear una plantilla era escribir HTML con variables de Go a mano.
Ahora se sube el documento que ya existe (.docx, .html, .txt o una foto del
papel), se lee —docx con stdlib, imágenes por OCR— y la IA lo devuelve armado
como plantilla con las variables del generador puestas donde iban los datos.

No se guarda solo: el HTML cae en el editor para revisarlo, y si la IA devolvió
sintaxis de plantilla inválida se avisa antes de guardar.

PDF queda afuera por ahora; el mensaje de error dice qué hacer en su lugar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 18:39:11 -05:00
Lizandro GuarnizoandClaude Opus 5 8c3ab79e88 feat(soporte): leer el buzón por IMAP, no solo esperar el webhook
Los clientes escriben a soporte@ desde su correo de siempre. Hasta ahora eso
solo llegaba si un proveedor (SendGrid/Mailgun) nos hacía POST; si nadie lo
configuraba, los correos quedaban sin leer en el buzón.

Ahora el cron entra al buzón cada 2 minutos, baja los no leídos, abre ticket
(o los engancha al hilo si son respuesta) y los marca como leídos. La lógica
de ingesta se movió a services para que webhook e IMAP se comporten igual.

La contraseña del buzón se guarda cifrada (AES-GCM con APP_KEY) y no vuelve
al navegador.

De paso, dos bugs que impedían guardar la configuración: el formulario mandaba
id=0 (gorm.Model serializa "ID"), así que cada guardado creaba una fila nueva
en vez de editar la que se usa; y Updates con struct ignoraba los booleanos en
false, así que desactivar algo no tenía efecto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 18:36:08 -05:00
109 changed files with 8641 additions and 1109 deletions
+4 -1
View File
@@ -44,6 +44,7 @@ require (
github.com/chai2010/webp v1.4.0 github.com/chai2010/webp v1.4.0
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe
github.com/chromedp/chromedp v0.16.0 github.com/chromedp/chromedp v0.16.0
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/go-sql-driver/mysql v1.8.1 github.com/go-sql-driver/mysql v1.8.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
@@ -55,6 +56,7 @@ require (
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.4 github.com/sirupsen/logrus v1.9.4
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.56.0
go.mongodb.org/mongo-driver v1.17.9 go.mongodb.org/mongo-driver v1.17.9
golang.org/x/net v0.53.0 golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.23.0 golang.org/x/oauth2 v0.23.0
@@ -75,6 +77,8 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/emicklei/go-restful/v3 v3.12.1 // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect
@@ -134,7 +138,6 @@ require (
github.com/tinylib/msgp v1.6.1 // indirect github.com/tinylib/msgp v1.6.1 // indirect
github.com/tklauser/numcpus v0.8.0 // indirect github.com/tklauser/numcpus v0.8.0 // indirect
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect
github.com/valyala/fasthttp v1.56.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect
+6
View File
@@ -175,6 +175,12 @@ github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5O
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+1
View File
@@ -203,6 +203,7 @@ func main() {
// dejó el refactor multi-agente. Sin esto no se puede insertar nada en // dejó el refactor multi-agente. Sin esto no se puede insertar nada en
// uMind (ver el comentario de la función). // uMind (ver el comentario de la función).
migrations.LiberarColumnasHuerfanasUmind() migrations.LiberarColumnasHuerfanasUmind()
migrations.IndicesUnicosMessageID()
migrations.MigrarUmindAgentes() migrations.MigrarUmindAgentes()
migrations.SeedApiKeys() migrations.SeedApiKeys()
if n, err := models.RepararEstadosTareaInvalidos(); err != nil { if n, err := models.RepararEstadosTareaInvalidos(); err != nil {
+34
View File
@@ -1622,3 +1622,37 @@ func SeedDocumentacion() {
} }
} }
} }
// IndicesUnicosMessageID crea índices únicos parciales sobre el Message-Id de
// los correos ya procesados. Es lo que hace que un mismo correo no pueda abrir
// dos tickets pase lo que pase: si el buzón vuelve a entregarlo, si el flag de
// leído no se guardó, o si dos instancias de la app lo leen a la vez, la base
// rechaza el segundo.
//
// Parcial (WHERE message_id <> ”) porque los tickets del portal no tienen
// Message-Id y todos comparten la cadena vacía.
func IndicesUnicosMessageID() {
db := app.Http.Database.DB
indices := []struct{ nombre, tabla string }{
{"idx_tickets_message_id_unico", "proyecto_tickets"},
{"idx_ticket_mensajes_message_id_unico", "ticket_mensajes"},
}
for _, ix := range indices {
var existe bool
if err := db.Raw(
`SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = ?)`, ix.nombre,
).Scan(&existe).Error; err != nil || existe {
continue
}
// Si ya hay duplicados de antes, el índice no se puede crear. Se avisa y
// se sigue: el chequeo previo en código igual filtra la mayoría.
sql := fmt.Sprintf(
`CREATE UNIQUE INDEX %s ON %s (message_id) WHERE message_id <> '' AND deleted_at IS NULL`,
ix.nombre, ix.tabla)
if err := db.Exec(sql).Error; err != nil {
log.Printf("[MIGRATE] No se pudo crear %s (¿hay correos duplicados de antes?): %v", ix.nombre, err)
continue
}
log.Printf("[MIGRATE] Índice %s creado.", ix.nombre)
}
}
+1
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
<title>uMind Studio</title> <title>uMind Studio</title>
</head> </head>
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens, <!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
+4 -1
View File
@@ -2,10 +2,13 @@
import Sidebar from './components/Sidebar.vue' import Sidebar from './components/Sidebar.vue'
import { tema, alternarTema } from './lib/tema.js' import { tema, alternarTema } from './lib/tema.js'
import { menuAbierto } from './lib/ui.js' import { menuAbierto } from './lib/ui.js'
import DespertarUmind from './components/DespertarUmind.vue'
import UiIcono from './components/ui/UiIcono.vue'
</script> </script>
<template> <template>
<div class="min-h-screen flex"> <div class="min-h-screen flex">
<DespertarUmind />
<Sidebar /> <Sidebar />
<main class="flex-1 min-w-0 flex flex-col"> <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"> <header class="h-14 shrink-0 flex items-center gap-1 px-4 sm:px-6 border-b border-borde">
@@ -25,7 +28,7 @@ import { menuAbierto } from './lib/ui.js'
:title="tema === 'oscuro' ? 'Cambiar a claro' : 'Cambiar a oscuro'" :title="tema === 'oscuro' ? 'Cambiar a claro' : 'Cambiar a oscuro'"
@click="alternarTema" @click="alternarTema"
> >
<span class="text-base leading-none">{{ tema === 'oscuro' ? '☀️' : '🌙' }}</span> <UiIcono :nombre="tema === 'oscuro' ? 'sol' : 'luna'" :tam="17" />
</button> </button>
</header> </header>
<div class="flex-1 max-w-5xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 animate-aparecer"> <div class="flex-1 max-w-5xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 animate-aparecer">
@@ -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>
+18 -11
View File
@@ -1,4 +1,5 @@
<script setup> <script setup>
import UiIcono from './ui/UiIcono.vue'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api.js' import { api } from '../lib/api.js'
@@ -112,7 +113,7 @@ async function guardar() {
} }
async function eliminar(t) { async function eliminar(t) {
if (!confirm(`¿Eliminar el tenant "${t.nombre}"? Esto borra también sus agentes. No se puede deshacer.`)) return if (!confirm(`¿Eliminar el espacio "${t.nombre}"? Esto borra también sus agentes. No se puede deshacer.`)) return
await api.del(apiUmind(`/umind/tenants/${t.ID}`)) await api.del(apiUmind(`/umind/tenants/${t.ID}`))
if (tenantActivoId.value === String(t.ID)) router.push('/') if (tenantActivoId.value === String(t.ID)) router.push('/')
await cargar() await cargar()
@@ -140,9 +141,15 @@ onMounted(() => {
md:static md:h-screen md:sticky md:top-0 md:translate-x-0" md:static md:h-screen md:sticky md:top-0 md:translate-x-0"
:class="menuAbierto ? 'translate-x-0' : '-translate-x-full'" :class="menuAbierto ? 'translate-x-0' : '-translate-x-full'"
> >
<div class="h-14 px-4 flex items-center border-b border-borde"> <div class="h-14 px-4 flex items-center gap-2 border-b border-borde">
<router-link to="/" class="text-base font-semibold text-texto"> <router-link to="/" class="flex items-center gap-2 text-base font-semibold text-texto">
uMind <span class="text-brand">Studio</span> <svg viewBox="0 0 96 96" class="w-6 h-6 shrink-0" aria-hidden="true">
<rect width="96" height="96" rx="22" fill="#8eb02f" />
<path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" />
<path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" />
<circle cx="60" cy="28" r="7" fill="#fff" />
</svg>
<span>uMind <span class="text-brand">Studio</span></span>
</router-link> </router-link>
</div> </div>
@@ -151,7 +158,7 @@ onMounted(() => {
class="btn-primary w-full" class="btn-primary w-full"
@click="nuevoTenant" @click="nuevoTenant"
> >
+ Nuevo tenant + Nuevo espacio
</button> </button>
</div> </div>
@@ -160,7 +167,7 @@ onMounted(() => {
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5"> <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-tenue">Cargando...</p> <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"> <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.' }} {{ contexto.esPortal ? 'Todavía no tenés ningún espacio asignado. Escribinos y lo activamos.' : 'Sin espacios todavía.' }}
</p> </p>
<div <div
v-for="t in tenants" v-for="t in tenants"
@@ -185,14 +192,14 @@ onMounted(() => {
title="Editar" title="Editar"
@click="editarTenant(t)" @click="editarTenant(t)"
> >
<UiIcono nombre="lapiz" :tam="14" />
</button> </button>
<button <button
class="p-1 text-tenue hover:text-red-600" class="p-1 text-tenue hover:text-red-600"
title="Eliminar" title="Eliminar"
@click="eliminar(t)" @click="eliminar(t)"
> >
<UiIcono nombre="cerrar" :tam="14" />
</button> </button>
</div> </div>
</div> </div>
@@ -207,10 +214,10 @@ onMounted(() => {
> >
<div class="card w-full max-w-lg p-6 animate-escalar shadow-2xl"> <div class="card w-full max-w-lg p-6 animate-escalar shadow-2xl">
<h2 class="font-semibold text-texto mb-4"> <h2 class="font-semibold text-texto mb-4">
{{ editing ? 'Editar tenant' : 'Nuevo tenant' }} {{ editing ? 'Editar espacio' : 'Nuevo espacio' }}
</h2> </h2>
<p class="text-xs text-tenue 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. Un espacio es el negocio o sitio dueño de los dominios permitidos. La config de IA, tono y demás se configuran por agente, dentro del tenant.
</p> </p>
<form class="space-y-3" @submit.prevent="guardar"> <form class="space-y-3" @submit.prevent="guardar">
<div> <div>
@@ -241,7 +248,7 @@ onMounted(() => {
<option v-for="c in clientes" :key="c.ID" :value="c.ID">{{ c.nombre }}</option> <option v-for="c in clientes" :key="c.ID" :value="c.ID">{{ c.nombre }}</option>
</select> </select>
<p class="text-[11px] text-tenue mt-1"> <p class="text-[11px] text-tenue mt-1">
{{ clientes.length ? 'Define quién ve este tenant desde el portal.' : 'No hay clientes activos — creá uno en Clientes.' }} {{ clientes.length ? 'Define quién ve este espacio desde el portal.' : 'No hay clientes activos — creá uno en Clientes.' }}
</p> </p>
</div> </div>
<div> <div>
@@ -1,12 +1,30 @@
<script setup> <script setup>
defineProps({ icono: { type: String, default: '' }, titulo: String, detalle: String }) import UiMascota from './UiMascota.vue'
// Una pantalla vacía es la primera que ve alguien que recién empieza, y la que
// más veces ve el que todavía no configuró algo. Decir "Sin canales" y nada
// más deja al usuario resolviendo solo qué significa eso y qué hacer.
//
// La mascota lleva el estado: dormida cuando no pasó nada todavía, buscando
// cuando falta cargar información, alerta cuando algo se rompió. Se lee antes
// que el texto.
defineProps({
// Estado de Umi. Sin él, cae en 'durmiendo', que es el caso más común.
estado: { type: String, default: 'durmiendo' },
titulo: String,
detalle: String,
// Escape para los pocos casos donde un emoji dice más que la mascota.
icono: { type: String, default: '' },
})
</script> </script>
<template> <template>
<div class="flex flex-col items-center justify-center text-center py-12 px-6"> <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> <div v-if="icono" class="text-3xl mb-3 opacity-70">{{ icono }}</div>
<UiMascota v-else :estado="estado" :tam="64" class="mb-3 text-brand" />
<p class="text-sm font-medium text-texto">{{ titulo }}</p> <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> <p v-if="detalle" class="text-xs text-tenue mt-1.5 max-w-sm leading-relaxed">{{ detalle }}</p>
<div class="mt-4"><slot /></div> <div v-if="$slots.default" class="mt-4"><slot /></div>
</div> </div>
</template> </template>
+111
View File
@@ -0,0 +1,111 @@
<script setup>
// Un solo set de iconos para todo uMind.
//
// Antes convivían emojis a color (🌙 📊 🧠 ✍️) con símbolos tipográficos
// monocromos (✎ ✕ ← ↻). Dos lenguajes distintos en la misma pantalla se leen
// como descuido, y encima los emojis los dibuja el sistema operativo: el mismo
// panel se ve distinto en Mac, en Windows y en Android, sin que podamos hacer
// nada.
//
// Estos son SVG de trazo que heredan currentColor, así que toman el color del
// texto que los rodea y funcionan igual en tema claro y oscuro. Mismo grosor de
// línea y misma caja en todos: al lado uno de otro tienen el mismo peso visual.
defineProps({
nombre: { type: String, required: true },
tam: { type: [Number, String], default: 16 },
})
// Cada icono es una lista de [etiqueta, atributos]. No es una cadena de HTML a
// propósito: v-html dentro de un <svg> parsea como HTML y los <path> quedan sin
// el namespace de SVG — se insertan en el DOM pero no dibujan nada. Declarados
// así, Vue los crea con el namespace correcto porque ve el <svg> padre.
const ICONOS = {
luna: [['path', { d: 'M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z' }]],
sol: [
['circle', { cx: 12, cy: 12, r: 4 }],
['path', { d: 'M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4' }],
],
lapiz: [
['path', { d: 'M12 20h9' }],
['path', { d: 'M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z' }],
],
cerrar: [['path', { d: 'M18 6 6 18M6 6l12 12' }]],
atras: [['path', { d: 'M19 12H5M12 19l-7-7 7-7' }]],
grafico: [
['path', { d: 'M3 3v18h18' }],
['path', { d: 'M7 15v3M12 10v8M17 6v12' }],
],
cerebro: [
['path', { d: 'M9.5 3A2.5 2.5 0 0 0 7 5.5v.2A2.7 2.7 0 0 0 5 8.3c0 .8.3 1.5.9 2A2.8 2.8 0 0 0 5 12.5c0 1 .5 1.9 1.3 2.4-.2.4-.3.8-.3 1.2A2.9 2.9 0 0 0 9 19a2.5 2.5 0 0 0 3 2V3.8A2.5 2.5 0 0 0 9.5 3Z' }],
['path', { d: 'M14.5 3A2.5 2.5 0 0 1 17 5.5v.2a2.7 2.7 0 0 1 2 2.6c0 .8-.3 1.5-.9 2A2.8 2.8 0 0 1 19 12.5c0 1-.5 1.9-1.3 2.4.2.4.3.8.3 1.2A2.9 2.9 0 0 1 15 19a2.5 2.5 0 0 1-3 2' }],
],
imagen: [
['rect', { x: 3, y: 3, width: 18, height: 18, rx: 2 }],
['circle', { cx: 8.5, cy: 8.5, r: 1.5 }],
['path', { d: 'm21 15-5-5L5 21' }],
],
microfono: [
['rect', { x: 9, y: 2, width: 6, height: 12, rx: 3 }],
['path', { d: 'M5 10a7 7 0 0 0 14 0M12 17v5' }],
],
escribir: [
['path', { d: 'M3 21h18' }],
['path', { d: 'M5 17 17.5 4.5a2.1 2.1 0 0 1 3 3L8 20l-4 1Z' }],
],
archivo: [
['path', { d: 'M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z' }],
['path', { d: 'M14 2v5h5' }],
],
sitio: [
['circle', { cx: 12, cy: 12, r: 9 }],
['path', { d: 'M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18Z' }],
],
refrescar: [
['path', { d: 'M21 12a9 9 0 1 1-3-6.7' }],
['path', { d: 'M21 4v5h-5' }],
],
descargar: [
['path', { d: 'M12 3v12' }],
['path', { d: 'm7 11 5 5 5-5' }],
['path', { d: 'M4 21h16' }],
],
alerta: [
['path', { d: 'M12 9v4M12 17h.01' }],
['path', { d: 'M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z' }],
],
ok: [['path', { d: 'M20 6 9 17l-5-5' }]],
copiar: [
['rect', { x: 9, y: 9, width: 12, height: 12, rx: 2 }],
['path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }],
],
mensaje: [['path', { d: 'M21 11.5a8.4 8.4 0 0 1-9 8.4 8.5 8.5 0 0 1-3.9-.9L3 21l1.9-5.1A8.4 8.4 0 0 1 4 11.5a8.5 8.5 0 0 1 8.5-8.5A8.4 8.4 0 0 1 21 11.5Z' }]],
auto: [
['path', { d: 'M17 2.1 21 6l-4 3.9' }],
['path', { d: 'M3 11V9a4 4 0 0 1 4-4h14' }],
['path', { d: 'M7 21.9 3 18l4-3.9' }],
['path', { d: 'M21 13v2a4 4 0 0 1-4 4H3' }],
],
}
</script>
<template>
<svg
:width="tam"
:height="tam"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class="inline-block shrink-0 align-[-0.15em]"
aria-hidden="true"
>
<component
v-for="(el, i) in ICONOS[nombre] || []"
:key="i"
:is="el[0]"
v-bind="el[1]"
/>
</svg>
</template>
@@ -0,0 +1,118 @@
<script setup>
// Umi, la mascota de uMind.
//
// No es un dibujo suelto: sale del logo. El cuerpo es la "u" del monograma y
// el punto del logo pasa a ser su antena, así que el símbolo de la marca y el
// personaje son la misma forma vista dos veces. Por eso funciona al lado del
// logo sin competirle.
//
// El estado no es decoración: cada pantalla vacía dice algo distinto, y la
// cara lo dice antes que el texto. Dormida cuando no pasó nada todavía,
// buscando cuando falta cargarle información, alerta cuando algo se rompió.
defineProps({
estado: {
type: String,
default: 'normal', // normal | durmiendo | contenta | pensando | buscando | alerta
},
tam: { type: [Number, String], default: 72 },
})
</script>
<template>
<svg
:width="tam"
:height="tam"
viewBox="0 0 120 120"
class="shrink-0"
role="img"
:aria-label="`Umi, la mascota de uMind (${estado})`"
>
<!-- Antena: el punto del logo. Cambia de color solo cuando algo anda mal,
para que el rojo signifique algo. -->
<circle
cx="60"
cy="26"
r="6"
:fill="estado === 'alerta' ? '#dc2626' : 'currentColor'"
:class="estado === 'pensando' ? 'umi-late' : ''"
/>
<line
x1="60" y1="32" x2="60" y2="44"
:stroke="estado === 'alerta' ? '#dc2626' : 'currentColor'"
stroke-width="4"
stroke-linecap="round"
/>
<!-- Cuerpo: la "u" del logo, cerrada. -->
<path d="M30,46 V76 A30,30 0 0 0 90,76 V46 Z" fill="currentColor" />
<!-- Ojos abiertos -->
<template v-if="estado === 'normal' || estado === 'contenta' || estado === 'alerta'">
<circle cx="47" :cy="estado === 'contenta' ? 64 : 66" r="6" class="umi-ojo" />
<circle cx="73" :cy="estado === 'contenta' ? 64 : 66" r="6" class="umi-ojo" />
<circle cx="48" :cy="estado === 'contenta' ? 65 : 67" r="3" class="umi-pupila" />
<circle cx="74" :cy="estado === 'contenta' ? 65 : 67" r="3" class="umi-pupila" />
</template>
<!-- Ojos cerrados: no pasó nada todavía, no hay nada roto -->
<template v-else-if="estado === 'durmiendo'">
<path d="M41,66 h12 M67,66 h12" class="umi-linea" stroke-width="5" stroke-linecap="round" fill="none" />
</template>
<!-- Pensando: los tres puntos de "escribiendo…" que ya conoce cualquiera -->
<template v-else-if="estado === 'pensando'">
<circle cx="45" cy="66" r="4.5" class="umi-ojo umi-p1" />
<circle cx="60" cy="66" r="4.5" class="umi-ojo umi-p2" />
<circle cx="75" cy="66" r="4.5" class="umi-ojo umi-p3" />
</template>
<!-- Buscando: mira de costado, como quien revisa algo -->
<template v-else-if="estado === 'buscando'">
<circle cx="47" cy="66" r="6" class="umi-ojo" />
<circle cx="73" cy="66" r="6" class="umi-ojo" />
<circle cx="50" cy="66" r="3" class="umi-pupila" />
<circle cx="76" cy="66" r="3" class="umi-pupila" />
</template>
<!-- Sonrisa solo cuando hay algo que celebrar -->
<path
v-if="estado === 'contenta'"
d="M50,78 q10,8 20,0"
class="umi-linea"
stroke-width="4"
fill="none"
stroke-linecap="round"
/>
</svg>
</template>
<style scoped>
/* Los ojos toman el color de la superficie que tienen detrás, no blanco fijo:
así la mascota se apoya sobre cualquier tarjeta sin un halo alrededor de
cada ojo, y en modo oscuro no quedan dos puntos blancos flotando.
Los tokens del proyecto son tripletes RGB, de ahí el rgb(). */
.umi-ojo { fill: rgb(var(--superficie)); }
.umi-linea { stroke: rgb(var(--superficie)); }
/* La pupila sí es fija: es lo único que da la sensación de mirada, y tiene
que leerse igual sobre el verde en los dos temas. */
.umi-pupila { fill: #11150F; }
.umi-late { animation: umi-latido 2s ease-in-out infinite; }
@keyframes umi-latido {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.umi-p1, .umi-p2, .umi-p3 { animation: umi-punto 1.4s ease-in-out infinite; }
.umi-p2 { animation-delay: 0.18s; }
.umi-p3 { animation-delay: 0.36s; }
@keyframes umi-punto {
0%, 60%, 100% { opacity: 0.35; }
30% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.umi-late, .umi-p1, .umi-p2, .umi-p3 { animation: none; opacity: 1; }
}
</style>
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup> <script setup>
import UiIcono from './UiIcono.vue'
defineProps({ titulo: { type: String, default: '' }, ancho: { type: String, default: 'max-w-lg' } }) defineProps({ titulo: { type: String, default: '' }, ancho: { type: String, default: 'max-w-lg' } })
const emit = defineEmits(['cerrar']) const emit = defineEmits(['cerrar'])
</script> </script>
@@ -12,7 +13,7 @@ const emit = defineEmits(['cerrar'])
<div :class="['card w-full p-6 max-h-[90vh] overflow-y-auto animate-escalar shadow-2xl', ancho]"> <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"> <div v-if="titulo" class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-texto">{{ titulo }}</h2> <h2 class="font-semibold text-texto">{{ titulo }}</h2>
<button class="btn-ghost !px-2 !py-1" type="button" @click="emit('cerrar')"></button> <button class="btn-ghost !px-2 !py-1" type="button" @click="emit('cerrar')"><UiIcono nombre="cerrar" :tam="15" /></button>
</div> </div>
<slot /> <slot />
</div> </div>
+2
View File
@@ -3,6 +3,7 @@ import Home from './views/Home.vue'
import TenantAgentes from './views/TenantAgentes.vue' import TenantAgentes from './views/TenantAgentes.vue'
import AgenteDetail from './views/AgenteDetail.vue' import AgenteDetail from './views/AgenteDetail.vue'
import Uso from './views/Uso.vue' import Uso from './views/Uso.vue'
import AiPropia from './views/AiPropia.vue'
import { contexto } from './lib/contexto.js' import { contexto } from './lib/contexto.js'
const router = createRouter({ const router = createRouter({
@@ -11,6 +12,7 @@ const router = createRouter({
{ path: '/', name: 'home', component: Home }, { path: '/', name: 'home', component: Home },
{ path: '/tenants/:id', name: 'tenant-agentes', component: TenantAgentes, props: true }, { path: '/tenants/:id', name: 'tenant-agentes', component: TenantAgentes, props: true },
{ path: '/tenants/:id/uso', name: 'uso', component: Uso, props: true }, { path: '/tenants/:id/uso', name: 'uso', component: Uso, props: true },
{ path: '/tenants/:id/ia', name: 'ai-propia', component: AiPropia, props: true },
{ {
path: '/tenants/:tenantId/agentes/:agenteId', path: '/tenants/:tenantId/agentes/:agenteId',
name: 'agente-detail', name: 'agente-detail',
+57
View File
@@ -29,6 +29,28 @@
color: rgb(var(--texto)); color: rgb(var(--texto));
-webkit-font-smoothing: antialiased; -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 { @layer components {
@@ -86,3 +108,38 @@
@apply tab text-tenue hover:text-texto hover:bg-elevado; @apply tab text-tenue hover:text-texto hover:bg-elevado;
} }
} }
@layer components {
/* Deslizable con el dedo, sin la barra gris encima del contenido. El borde
cortado de la última pestaña ya avisa que hay más hacia el costado. */
.sin-barra {
scrollbar-width: none;
-ms-overflow-style: none;
}
.sin-barra::-webkit-scrollbar {
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; }
}
}
File diff suppressed because it is too large Load Diff
+256
View File
@@ -0,0 +1,256 @@
<script setup>
import { ref, watch } from 'vue'
import { api } from '../lib/api.js'
import { apiUmind } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
import UiIcono from '../components/ui/UiIcono.vue'
const props = defineProps({ id: { type: String, required: true } })
const items = ref([])
const cargando = ref(true)
const error = ref('')
const showForm = ref(false)
const editando = ref(null)
const guardando = ref(false)
const probando = ref(null)
const resultado = ref({})
// Los proveedores que acepta el backend. El texto de ayuda de cada uno importa
// más que la lista: nadie recuerda de memoria el nombre exacto de un modelo.
const proveedores = [
{ valor: 'openai', nombre: 'OpenAI', modelo: 'gpt-4o-mini', donde: 'platform.openai.com/api-keys' },
{ valor: 'anthropic', nombre: 'Anthropic (Claude)', modelo: 'claude-sonnet-4-20250514', donde: 'console.anthropic.com' },
{ valor: 'gemini', nombre: 'Google Gemini', modelo: 'gemini-2.0-flash', donde: 'aistudio.google.com/apikey' },
{ valor: 'groq', nombre: 'Groq', modelo: 'llama-3.3-70b-versatile', donde: 'console.groq.com/keys' },
{ valor: 'deepseek', nombre: 'DeepSeek', modelo: 'deepseek-chat', donde: 'platform.deepseek.com' },
{ valor: 'qwen', nombre: 'Qwen (Alibaba)', modelo: 'qwen2.5-72b-instruct', donde: 'dashscope.console.aliyun.com' },
{ valor: 'ollama', nombre: 'Ollama (tu servidor)', modelo: 'llama3.1', donde: '' },
]
function vacio() {
return { nombre: '', provider: 'openai', api_key: '', base_url: '', model_name: '' }
}
const form = ref(vacio())
function proveedorDe(valor) {
return proveedores.find((p) => p.valor === valor) || proveedores[0]
}
async function cargar() {
cargando.value = true
error.value = ''
try {
const r = await api.get(apiUmind(`/umind/ai-propia?tenant_id=${props.id}`))
items.value = r.items || []
} catch (e) {
error.value = e.message
} finally {
cargando.value = false
}
}
watch(() => props.id, cargar, { immediate: true })
function nueva() {
editando.value = null
form.value = vacio()
showForm.value = true
}
function editar(c) {
editando.value = c
// La clave no vuelve del servidor: se deja en blanco y solo se manda si la
// persona escribe una nueva.
form.value = {
nombre: c.nombre,
provider: c.provider,
api_key: '',
base_url: c.base_url || '',
model_name: c.model_name || '',
}
showForm.value = true
}
async function guardar() {
guardando.value = true
error.value = ''
try {
const cuerpo = { tenant_id: Number(props.id), ...form.value }
if (editando.value) {
await api.put(apiUmind(`/umind/ai-propia/${editando.value.ID}`), cuerpo)
} else {
await api.post(apiUmind('/umind/ai-propia'), cuerpo)
}
showForm.value = false
await cargar()
} catch (e) {
error.value = e.message
} finally {
guardando.value = false
}
}
// Probar contra el proveedor de verdad. Una clave vencida no se nota hasta que
// alguien escribe: esto lo adelanta.
async function probar(c) {
probando.value = c.ID
resultado.value = { ...resultado.value, [c.ID]: null }
try {
const r = await api.post(apiUmind(`/umind/ai-propia/${c.ID}/probar`), {})
resultado.value = { ...resultado.value, [c.ID]: { ok: true, texto: r.respuesta } }
} catch (e) {
resultado.value = { ...resultado.value, [c.ID]: { ok: false, texto: e.message } }
} finally {
probando.value = null
}
}
async function borrar(c) {
if (!confirm(`¿Eliminar "${c.nombre}"?`)) return
error.value = ''
try {
await api.del(apiUmind(`/umind/ai-propia/${c.ID}`))
await cargar()
} catch (e) {
error.value = e.message
}
}
</script>
<template>
<div>
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<router-link :to="`/tenants/${id}`" class="text-sm text-tenue hover:text-texto inline-flex items-center gap-1.5"><UiIcono nombre="atras" :tam="14" /> Agentes</router-link>
<h1 class="text-xl font-semibold text-texto mt-1">Tu cuenta de IA</h1>
<p class="text-sm text-tenue mt-1 max-w-xl">
Podés usar tu propia cuenta de OpenAI, Claude o Gemini en vez de la nuestra.
El consumo se factura directo con el proveedor y no pasa por tu plan.
</p>
</div>
<button class="btn-primary shrink-0" @click="nueva">+ Conectar cuenta</button>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
<div v-if="cargando" class="text-sm text-tenue">Cargando</div>
<UiEmptyState
v-else-if="items.length === 0"
class="card"
titulo="Estás usando la IA de uMind"
detalle="Funciona sin que hagas nada. Conectá tu propia cuenta solo si querés facturar el consumo por tu lado o usar un modelo puntual."
>
<button class="btn-primary" @click="nueva">Conectar mi cuenta</button>
</UiEmptyState>
<div v-else class="card divide-y divide-borde">
<div v-for="c in items" :key="c.ID" class="p-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-sm text-texto font-medium">{{ c.nombre }}</div>
<div class="text-xs text-tenue mt-0.5">
{{ proveedorDe(c.provider).nombre }} · {{ c.model_name }} ·
<span class="font-mono">{{ c.api_key_pista }}</span>
</div>
<p
v-if="resultado[c.ID]"
class="text-xs mt-1.5"
:class="resultado[c.ID].ok ? 'text-brand' : 'text-red-600 dark:text-red-400'"
>
{{ resultado[c.ID].ok ? `Responde bien — dijo "${resultado[c.ID].texto}"` : resultado[c.ID].texto }}
</p>
</div>
<div class="flex items-center gap-3 shrink-0 text-sm">
<button
class="text-brand hover:underline disabled:opacity-40"
:disabled="probando === c.ID"
@click="probar(c)"
>{{ probando === c.ID ? 'Probando…' : 'Probar' }}</button>
<button class="text-tenue hover:text-texto" @click="editar(c)">Editar</button>
<button class="text-red-500 hover:text-red-700" @click="borrar(c)">Eliminar</button>
</div>
</div>
</div>
</div>
<p v-if="items.length" class="text-xs text-tenue mt-3">
Para que un agente la use, elegila en su campo <strong>Config de IA</strong>.
</p>
<!-- Alta / edición -->
<div
v-if="showForm"
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
@click.self="showForm = false"
>
<div class="card w-full max-w-lg p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
<h2 class="font-semibold text-texto mb-4">
{{ editando ? 'Editar cuenta de IA' : 'Conectar tu cuenta de IA' }}
</h2>
<form class="space-y-3" @submit.prevent="guardar">
<div>
<label class="label">Nombre</label>
<input v-model="form.nombre" required placeholder="ej: Mi cuenta de OpenAI" class="input" />
</div>
<div>
<label class="label">Proveedor</label>
<select v-model="form.provider" class="input">
<option v-for="p in proveedores" :key="p.valor" :value="p.valor">{{ p.nombre }}</option>
</select>
<p v-if="proveedorDe(form.provider).donde" class="text-xs text-tenue mt-1">
La clave se saca de <span class="font-mono">{{ proveedorDe(form.provider).donde }}</span>
</p>
</div>
<div>
<label class="label">Modelo</label>
<input
v-model="form.model_name"
required
:placeholder="`ej: ${proveedorDe(form.provider).modelo}`"
class="input font-mono"
/>
</div>
<div v-if="form.provider !== 'ollama'">
<label class="label">
Clave de API
<span v-if="editando" class="text-tenue font-normal"> dejala vacía para no cambiarla</span>
</label>
<input
v-model="form.api_key"
type="password"
autocomplete="new-password"
:required="!editando"
placeholder="sk-..."
class="input font-mono"
/>
<p class="text-xs text-tenue mt-1">Se guarda cifrada y no se vuelve a mostrar.</p>
</div>
<div v-if="form.provider === 'ollama'">
<label class="label">URL de tu servidor</label>
<input
v-model="form.base_url"
required
placeholder="https://ollama.tudominio.com/v1"
class="input font-mono"
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="btn-ghost" @click="showForm = false">Cancelar</button>
<button type="submit" class="btn-primary" :disabled="guardando">
{{ guardando ? 'Guardando…' : 'Guardar' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
+26 -2
View File
@@ -3,9 +3,11 @@ import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { api } from '../lib/api.js' import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js' import { apiUmind, contexto } from '../lib/contexto.js'
import UiMascota from '../components/ui/UiMascota.vue'
const router = useRouter() const router = useRouter()
const cargando = ref(contexto.esPortal) const cargando = ref(contexto.esPortal)
const sinEspacios = ref(false)
// El cliente no tiene "tenants": tiene SU asistente. Si solo hay un espacio // El cliente no tiene "tenants": tiene SU asistente. Si solo hay un espacio
// con un agente —el caso normal— entrar obligándolo a elegir dos veces es // con un agente —el caso normal— entrar obligándolo a elegir dos veces es
@@ -18,6 +20,10 @@ async function irAlAgenteSiEsUnoSolo() {
try { try {
const t = await api.get(apiUmind('/umind/tenants')) const t = await api.get(apiUmind('/umind/tenants'))
const tenants = t.items || [] const tenants = t.items || []
if (tenants.length === 0) {
sinEspacios.value = true
return
}
if (tenants.length !== 1) return if (tenants.length !== 1) return
const tenantId = tenants[0].ID const tenantId = tenants[0].ID
@@ -43,10 +49,28 @@ onMounted(irAlAgenteSiEsUnoSolo)
Abriendo tu asistente Abriendo tu asistente
</div> </div>
<!-- Sin espacios no hay nada que elegir ni forma de crearlo: mandarlo a
"elegí de la izquierda" cuando la izquierda está vacía es un callejón. -->
<div v-else-if="sinEspacios" class="max-w-md mx-auto text-center py-20">
<UiMascota estado="durmiendo" :tam="80" class="mx-auto mb-4 text-brand" />
<h1 class="text-lg font-medium text-texto">Todavía no tenés un asistente activo</h1>
<p class="text-sm text-tenue mt-2">
uMind contesta por vos en WhatsApp y en tu sitio, con la información de tu negocio.
Entiende las notas de voz y lee las fotos y archivos que te mandan tus clientes.
</p>
<a
class="btn-primary inline-block mt-5"
href="mailto:soporte@u-site.app?subject=Quiero%20activar%20uMind"
>Quiero activarlo</a>
<p class="text-xs text-tenue mt-4">
<a href="/portal/dashboard" class="underline">Volver al portal</a>
</p>
</div>
<div v-else class="flex flex-col items-center justify-center text-center py-24"> <div v-else class="flex flex-col items-center justify-center text-center py-24">
<div class="text-4xl mb-4">💬</div> <UiMascota estado="normal" :tam="64" class="mx-auto mb-4 text-brand" />
<h1 class="text-lg font-medium text-texto"> <h1 class="text-lg font-medium text-texto">
{{ contexto.esPortal ? 'Elegí tu espacio de la izquierda' : 'Elegí un tenant de la izquierda' }} {{ contexto.esPortal ? 'Elegí tu espacio de la izquierda' : 'Elegí un espacio de la izquierda' }}
</h1> </h1>
<p class="text-sm text-tenue mt-1"> <p class="text-sm text-tenue mt-1">
{{ contexto.esPortal {{ contexto.esPortal
+71 -8
View File
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { api } from '../lib/api.js' import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js' import { apiUmind, contexto } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue' import UiEmptyState from '../components/ui/UiEmptyState.vue'
import UiIcono from '../components/ui/UiIcono.vue'
const props = defineProps({ id: { type: String, required: true } }) const props = defineProps({ id: { type: String, required: true } })
const tenantId = computed(() => Number(props.id)) const tenantId = computed(() => Number(props.id))
@@ -62,23 +63,30 @@ const cupo = computed(() => {
}) })
function vacio() { function vacio() {
return { nombre: '', ai_config_id: null, tono: '', mensaje_bienvenida: '', color: '#8eb02f', activo: true } return { nombre: '', ai_config_id: null, tono: '', mensaje_bienvenida: '', color: '#8eb02f', activo: true, plantilla_rubro: '' }
} }
// Puntos de partida por rubro. Un agente vacío no sirve el primer día, y
// escribir el conocimiento desde cero frente a un campo en blanco es donde la
// mayoría abandona.
const plantillas = ref([])
async function cargar() { async function cargar() {
error.value = '' error.value = ''
cargando.value = true cargando.value = true
try { try {
const [t, a, ai] = await Promise.all([ const [t, a, ai, pl] = await Promise.all([
api.get(apiUmind('/umind/tenants')), api.get(apiUmind('/umind/tenants')),
api.get(apiUmind(`/umind/agentes?tenant_id=${props.id}`)), api.get(apiUmind(`/umind/agentes?tenant_id=${props.id}`)),
api.get(apiUmind('/umind/ai-configs')), api.get(apiUmind('/umind/ai-configs')),
api.get(apiUmind('/umind/plantillas-rubro')),
]) ])
tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null
agentes.value = a.items || [] agentes.value = a.items || []
plan.value = a.plan || null plan.value = a.plan || null
resumen.value = a.resumen || {} resumen.value = a.resumen || {}
aiConfigs.value = ai.items || [] aiConfigs.value = ai.items || []
plantillas.value = pl.items || []
} catch (e) { } catch (e) {
error.value = e.message error.value = e.message
} finally { } finally {
@@ -86,6 +94,24 @@ async function cargar() {
} }
} }
// Un agente que ya funciona es la mejor plantilla del siguiente: el catálogo de
// rubros da un arranque genérico, esto copia uno real con su conocimiento.
async function duplicarAgente(a) {
const nombre = prompt(`Nombre de la copia de "${a.nombre}":`, `${a.nombre} (copia)`)
if (nombre === null) return
error.value = ''
try {
const r = await api.post(apiUmind(`/umind/agentes/${a.ID}/duplicar`), {
tenant_id: Number(tenantId.value),
nombre: nombre.trim(),
})
await cargar()
if (r.aviso) alert(r.aviso)
} catch (e) {
error.value = e.message
}
}
function nuevoAgente() { function nuevoAgente() {
editing.value = null editing.value = null
form.value = vacio() form.value = vacio()
@@ -142,7 +168,8 @@ watch(() => props.id, cargar, { immediate: true })
<h1 class="text-xl font-semibold text-texto">{{ tenant.nombre }}</h1> <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> <p class="text-xs text-tenue mt-1">{{ tenant.dominios_permitidos || 'sin dominios configurados' }}</p>
</div> </div>
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost">📊 Consumo</router-link> <router-link :to="`/tenants/${tenantId ?? id}/ia`" class="btn-ghost">Tu IA</router-link>
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost inline-flex items-center gap-1.5"><UiIcono nombre="grafico" /> Consumo</router-link>
</div> </div>
</div> </div>
@@ -160,7 +187,7 @@ watch(() => props.id, cargar, { immediate: true })
<button class="btn-primary" :disabled="cupo.lleno" @click="nuevoAgente">+ Nuevo agente</button> <button class="btn-primary" :disabled="cupo.lleno" @click="nuevoAgente">+ Nuevo agente</button>
</div> </div>
<p v-if="cupo.sinPlan && !contexto.esPortal" class="text-xs text-tenue -mt-2 mb-4"> <p v-if="cupo.sinPlan && !contexto.esPortal" class="text-xs text-tenue -mt-2 mb-4">
Este tenant no tiene plan asignado, así que no se le aplica ningún límite de agentes. Asignale uno desde el lápiz del tenant en la barra izquierda. Este espacio no tiene plan asignado, así que no se le aplica ningún límite de agentes. Asignale uno desde el lápiz del espacio, en la barra izquierda.
</p> </p>
<p v-else-if="cupo.lleno" class="text-xs text-tenue -mt-2 mb-4"> <p v-else-if="cupo.lleno" class="text-xs text-tenue -mt-2 mb-4">
Alcanzaste el máximo de agentes de tu plan. Alcanzaste el máximo de agentes de tu plan.
@@ -184,7 +211,7 @@ watch(() => props.id, cargar, { immediate: true })
<UiEmptyState <UiEmptyState
v-else-if="agentes.length === 0" v-else-if="agentes.length === 0"
class="card" class="card"
icono="🤖" estado="durmiendo"
titulo="Todavía no hay agentes" titulo="Todavía no hay agentes"
detalle="Creá el primero y cargale su base de conocimiento para que empiece a responder." detalle="Creá el primero y cargale su base de conocimiento para que empiece a responder."
> >
@@ -217,8 +244,13 @@ watch(() => props.id, cargar, { immediate: true })
</div> </div>
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"> <div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button class="p-1 text-tenue hover:text-texto" title="Editar" @click.prevent.stop="editarAgente(a)"></button> <button class="p-1 text-tenue hover:text-texto" title="Editar" @click.prevent.stop="editarAgente(a)"><UiIcono nombre="lapiz" :tam="14" /></button>
<button class="p-1 text-tenue hover:text-red-600" title="Eliminar" @click.prevent.stop="eliminarAgente(a)"></button> <button
class="p-1 text-tenue hover:text-texto"
title="Duplicar: copia el conocimiento y las herramientas a un agente nuevo"
@click.prevent.stop="duplicarAgente(a)"
><UiIcono nombre="copiar" :tam="14" /></button>
<button class="p-1 text-tenue hover:text-red-600" title="Eliminar" @click.prevent.stop="eliminarAgente(a)"><UiIcono nombre="cerrar" :tam="14" /></button>
</div> </div>
</div> </div>
@@ -231,13 +263,44 @@ watch(() => props.id, cargar, { immediate: true })
</div> </div>
<div v-if="showForm" class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" @click.self="showForm = false"> <div v-if="showForm" 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 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-lg"> <!-- max-h + scroll propio: con las opciones de plantilla el formulario
pasa de largo la pantalla, y sin esto el botón de guardar queda
abajo del borde inferior, inalcanzable. -->
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 w-full max-w-lg max-h-[calc(100vh-2rem)] overflow-y-auto">
<h2 class="font-semibold text-gray-800 dark:text-gray-100 mb-4">{{ editing ? 'Editar agente' : 'Nuevo agente' }}</h2> <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"> <form class="space-y-3" @submit.prevent="guardar">
<div> <div>
<label class="label">Nombre</label> <label class="label">Nombre</label>
<input v-model="form.nombre" required placeholder="ej: Ventas, Soporte" class="input" /> <input v-model="form.nombre" required placeholder="ej: Ventas, Soporte" class="input" />
</div> </div>
<!-- Solo al crear: en un agente que ya existe, precargar notas
pisaría el conocimiento que el dueño ya escribió. -->
<div v-if="!editing && plantillas.length">
<label class="label">Arrancar con</label>
<div class="grid gap-1.5">
<label
v-for="p in [{ clave: '', nombre: 'Agente en blanco', descripcion: 'Sin conocimiento cargado. Lo escribís vos desde cero.' }, ...plantillas]"
:key="p.clave"
class="flex gap-2.5 p-2.5 rounded-lg border cursor-pointer transition-colors"
:class="form.plantilla_rubro === p.clave
? 'border-brand bg-brand/5'
: 'border-borde hover:border-brand/40'"
>
<input v-model="form.plantilla_rubro" type="radio" :value="p.clave" class="mt-1 text-brand focus:ring-brand" />
<span class="min-w-0">
<span class="block text-sm text-texto">{{ p.nombre }}</span>
<span class="block text-xs text-tenue">{{ p.descripcion }}</span>
<span v-if="p.notas" class="block text-xs text-tenue mt-0.5">
{{ p.notas }} notas listas para editar · {{ p.resumen }}
</span>
</span>
</label>
</div>
<p class="text-xs text-tenue mt-1.5">
Las notas vienen con ejemplos entre corchetes abrilas y reemplazalas por tus datos reales.
</p>
</div>
<div> <div>
<label class="label">Config de IA</label> <label class="label">Config de IA</label>
<select v-model="form.ai_config_id" class="input"> <select v-model="form.ai_config_id" class="input">
+14 -8
View File
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
import { api } from '../lib/api.js' import { api } from '../lib/api.js'
import { apiUmind } from '../lib/contexto.js' import { apiUmind } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue' import UiEmptyState from '../components/ui/UiEmptyState.vue'
import UiIcono from '../components/ui/UiIcono.vue'
const props = defineProps({ id: { type: String, required: true } }) const props = defineProps({ id: { type: String, required: true } })
@@ -16,11 +17,11 @@ const desde = ref(new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().s
const hasta = ref(hoy.toISOString().slice(0, 10)) const hasta = ref(hoy.toISOString().slice(0, 10))
const ETIQUETAS = { const ETIQUETAS = {
ia: { nombre: 'Inteligencia artificial', icono: '🧠', color: '#8eb02f' }, ia: { nombre: 'Inteligencia artificial', icono: 'cerebro', color: '#8eb02f' },
ocr: { nombre: 'Lectura de imágenes', icono: '🖼️', color: '#2f7fb0' }, ocr: { nombre: 'Lectura de imágenes', icono: 'imagen', color: '#2f7fb0' },
whisper: { nombre: 'Transcripción de audio', icono: '🎙️', color: '#b0752f' }, whisper: { nombre: 'Transcripción de audio', icono: 'microfono', color: '#b0752f' },
} }
const etiqueta = (t) => ETIQUETAS[t] || { nombre: t, icono: '', color: '#94a3b8' } const etiqueta = (t) => ETIQUETAS[t] || { nombre: t, icono: 'grafico', color: '#94a3b8' }
async function cargar() { async function cargar() {
cargando.value = true cargando.value = true
@@ -88,7 +89,7 @@ watch(() => props.id, cargar, { immediate: true })
<h1 class="text-xl font-semibold text-texto">Consumo</h1> <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> <p class="text-sm text-tenue mt-0.5">Lo que usaron tus agentes y cuánto cuesta.</p>
</div> </div>
<router-link :to="`/tenants/${id}`" class="btn-ghost"> Agentes</router-link> <router-link :to="`/tenants/${id}`" class="btn-ghost inline-flex items-center gap-1.5"><UiIcono nombre="atras" :tam="15" /> Agentes</router-link>
</div> </div>
<!-- Filtros --> <!-- Filtros -->
@@ -162,19 +163,24 @@ watch(() => props.id, cargar, { immediate: true })
</div> </div>
<UiEmptyState <UiEmptyState
v-if="resumen.length === 0" v-if="resumen.length === 0"
icono="📊" estado="durmiendo"
titulo="Sin consumo en este período" titulo="Sin consumo en este período"
detalle="Cuando tus agentes respondan mensajes, lean imágenes o transcriban audios, vas a verlo acá." 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"> <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> <UiIcono :nombre="etiqueta(r.tipo).icono" :tam="18" class="text-tenue" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm text-texto">{{ etiqueta(r.tipo).nombre }}</p> <p class="text-sm text-texto">{{ etiqueta(r.tipo).nombre }}</p>
<p class="text-xs text-tenue tabular-nums"> <p class="text-xs text-tenue tabular-nums">
{{ numero(r.cantidad) }} {{ r.unidad }} · {{ numero(r.eventos) }} usos {{ numero(r.cantidad) }} {{ r.unidad }} · {{ numero(r.eventos) }} usos
</p> </p>
</div> </div>
<span class="text-sm font-medium text-texto tabular-nums">{{ money(r.costo) }}</span> <!-- Costo cero con consumo real quiere decir cuenta propia: lo
paga su proveedor. Mostrar "$0" a secas parecería un error. -->
<span v-if="r.costo > 0" class="text-sm font-medium text-texto tabular-nums">{{ money(r.costo) }}</span>
<span v-else class="text-xs text-tenue text-right leading-tight">
va por tu<br />cuenta de IA
</span>
</div> </div>
</div> </div>
@@ -0,0 +1,110 @@
<script setup>
import { computed } from 'vue'
import UiIcono from '../../components/ui/UiIcono.vue'
// Responde "¿está bien mi asistente?" sin hacer un solo clic.
//
// Antes había que entrar pestaña por pestaña para saberlo: si estaba atendiendo
// en algún lado, si sabía algo, si alguien le había escrito. Tres datos que el
// dueño quiere de un vistazo y que estaban repartidos en tres pantallas.
const props = defineProps({
canales: { type: Array, default: () => [] },
documentos: { type: Array, default: () => [] },
sesiones: { type: Array, default: () => [] },
eventos: { type: Array, default: () => [] },
})
const emit = defineEmits(['ir'])
const canalesActivos = computed(() => props.canales.filter((c) => c.activo))
// Los tipos vienen en minúscula del backend; "Atendiendo en whatsapp" se lee
// como un error de tipeo.
const NOMBRES_CANAL = { whatsapp: 'WhatsApp', telegram: 'Telegram', web: 'tu web' }
const nombreCanal = (t) => NOMBRES_CANAL[t] || t
const fuentesListas = computed(() => props.documentos.filter((d) => d.estado === 'listo'))
// Dos meses es cuando una fuente empieza a ser sospechosa de estar vieja: es el
// mismo corte que usa la lista de conocimiento, para que no digan cosas
// distintas.
const fuentesViejas = computed(() =>
fuentesListas.value.filter(
(d) => d.procesado_at && Date.now() - new Date(d.procesado_at) > 60 * 86400000,
),
)
const problemas = computed(() => props.eventos.filter((e) => e.nivel === 'error').length)
// Solo conversaciones de clientes: la bandeja separa las pruebas propias, y si
// este número las incluyera diría una cosa distinta que la lista de al lado.
const reales = computed(() =>
props.sesiones.filter((s) => !String(s.session_id).startsWith('staff-preview')),
)
// Cada señal dice qué pasa y, cuando algo anda mal, a dónde ir a arreglarlo.
const señales = computed(() => [
{
clave: 'canales',
icono: 'mensaje',
bien: canalesActivos.value.length > 0,
texto: canalesActivos.value.length
? `Atendiendo en ${canalesActivos.value.map((c) => nombreCanal(c.tipo)).join(' y ')}`
: 'No está atendiendo en ningún lado',
destino: 'canales',
},
{
clave: 'conocimiento',
icono: 'escribir',
bien: fuentesListas.value.length > 0 && fuentesViejas.value.length === 0,
texto: !fuentesListas.value.length
? 'Todavía no sabe nada de tu negocio'
: fuentesViejas.value.length
? `${fuentesViejas.value.length} de ${fuentesListas.value.length} fuentes están viejas`
: `Sabe de ${fuentesListas.value.length} ${fuentesListas.value.length === 1 ? 'cosa' : 'cosas'}`,
destino: 'conocimiento',
},
{
clave: 'conversaciones',
icono: 'grafico',
// Que nadie haya escrito todavía no es un problema: es un dato.
bien: true,
neutro: reales.value.length === 0,
texto: reales.value.length
? `${reales.value.length} ${reales.value.length === 1 ? 'conversación' : 'conversaciones'}`
: 'Nadie escribió todavía',
destino: 'conversaciones',
},
])
</script>
<template>
<div class="card p-1 mb-6 flex flex-wrap">
<button
v-for="s in señales"
:key="s.clave"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-elevado transition-colors text-left"
@click="emit('ir', s.destino)"
>
<UiIcono
:nombre="s.bien ? s.icono : 'alerta'"
:tam="16"
:class="s.neutro ? 'text-tenue' : s.bien ? 'text-brand' : 'text-amber-600 dark:text-amber-400'"
/>
<span :class="s.neutro ? 'text-tenue' : s.bien ? 'text-texto' : 'text-amber-700 dark:text-amber-400 font-medium'">
{{ s.texto }}
</span>
</button>
<button
v-if="problemas"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm hover:bg-elevado transition-colors ml-auto"
@click="emit('ir', 'auditoria')"
>
<UiIcono nombre="alerta" :tam="16" class="text-red-600 dark:text-red-400" />
<span class="text-red-700 dark:text-red-400 font-medium">
{{ problemas }} {{ problemas === 1 ? 'problema' : 'problemas' }}
</span>
</button>
</div>
</template>
@@ -0,0 +1,107 @@
<script setup>
import { computed } from 'vue'
import UiIcono from '../../components/ui/UiIcono.vue'
import UiMascota from '../../components/ui/UiMascota.vue'
// El camino de puesta en marcha, en orden. Existe porque un agente recién
// creado mostraba tres avisos ámbar sueltos ("no atiende", "no sabe nada") y
// ningún orden: el dueño sabía que faltaban cosas pero no cuál iba primero.
//
// Mientras falte algo, esta guía reemplaza al estado de arriba — una sola voz
// por etapa. Cuando los tres pasos están hechos, desaparece para siempre y el
// estado operativo toma su lugar. La puesta en marcha se hace una vez; el
// estado se mira todos los días.
const props = defineProps({
documentos: { type: Array, default: () => [] },
canales: { type: Array, default: () => [] },
sesiones: { type: Array, default: () => [] },
})
const emit = defineEmits(['ir'])
const sabe = computed(() => props.documentos.some((d) => d.estado === 'listo'))
const probado = computed(() =>
props.sesiones.some((s) => String(s.session_id).startsWith('staff-preview')),
)
const atiende = computed(() => props.canales.some((c) => c.activo))
const pasos = computed(() => [
{
n: 1,
hecho: sabe.value,
titulo: 'Contale de tu negocio',
detalle: 'Escribí tus horarios, precios y lo que te preguntan siempre — o subí tu lista de precios, o dale tu sitio web.',
accion: 'Cargar información',
destino: 'conocimiento',
},
{
n: 2,
hecho: probado.value,
titulo: 'Hacele una pregunta',
detalle: 'Preguntale algo como lo haría un cliente. Si contesta mal, corregí su información y probá de nuevo.',
accion: 'Probarlo',
destino: 'conocimiento',
},
{
n: 3,
hecho: atiende.value,
titulo: 'Ponelo a atender',
detalle: 'Conectá tu WhatsApp o Telegram, o pegá el widget en tu web. Desde ese momento responde solo.',
accion: 'Elegir dónde',
destino: 'canales',
},
])
const hechos = computed(() => pasos.value.filter((p) => p.hecho).length)
// El siguiente paso pendiente es el único que se resalta: una guía donde todo
// grita a la vez no guía nada.
const siguiente = computed(() => pasos.value.find((p) => !p.hecho)?.n ?? null)
</script>
<template>
<div class="card p-5 mb-6">
<div class="flex items-start gap-4">
<UiMascota :estado="hechos === 0 ? 'durmiendo' : 'contenta'" :tam="52" class="text-brand shrink-0 hidden sm:block" />
<div class="min-w-0 flex-1">
<div class="flex items-baseline justify-between gap-3">
<h2 class="font-semibold text-texto">Pongamos a andar tu asistente</h2>
<span class="text-xs text-tenue tabular-nums shrink-0">{{ hechos }} de {{ pasos.length }}</span>
</div>
<p class="text-sm text-tenue mt-0.5 mb-4">
Tres pasos y empieza a responderle a tus clientes. Diez minutos, sin nada técnico.
</p>
<ol class="space-y-2.5">
<li
v-for="p in pasos"
:key="p.n"
class="flex items-start gap-3 rounded-lg p-2.5 -m-0.5 transition-colors"
:class="p.n === siguiente ? 'bg-brand/5 border border-brand/30' : ''"
>
<span
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 mt-0.5"
:class="p.hecho ? 'bg-brand text-white' : p.n === siguiente ? 'border-2 border-brand text-brand' : 'border border-borde text-tenue'"
>
<UiIcono v-if="p.hecho" nombre="ok" :tam="13" />
<template v-else>{{ p.n }}</template>
</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium" :class="p.hecho ? 'text-tenue line-through' : 'text-texto'">
{{ p.titulo }}
</p>
<p v-if="!p.hecho" class="text-xs text-tenue mt-0.5">{{ p.detalle }}</p>
</div>
<button
v-if="!p.hecho && p.n === siguiente"
class="btn-primary !py-1.5 !px-3 text-sm shrink-0"
@click="emit('ir', p.destino)"
>
{{ p.accion }}
</button>
</li>
</ol>
</div>
</div>
</div>
</template>
@@ -0,0 +1,49 @@
<script setup>
import { computed } from 'vue'
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
// Los eventos llegan del padre, que ya los carga junto con el resto del
// agente: pedirlos otra vez acá duplicaría la llamada cada vez que alguien
// entra a la pestaña.
defineProps({
eventos: { type: Array, default: () => [] },
})
const nivelColor = computed(() => (nivel) => ({
error: 'badge-error',
warn: 'badge-alerta',
}[nivel] || 'badge-neutro'))
function formatearFecha(iso) {
try {
return new Date(iso).toLocaleString()
} catch {
return iso
}
}
</script>
<template>
<div>
<p class="label mb-4">
Errores y eventos técnicos de este agente fallos al llamar a la IA, a una herramienta, al correo o a los canales. Últimos 100.
</p>
<div class="card divide-y divide-borde max-h-[32rem] overflow-y-auto">
<UiEmptyState
v-if="eventos.length === 0"
estado="contenta"
titulo="Ningún problema registrado"
detalle="Acá aparecen los errores: una herramienta que no responde, una fuente que no se pudo leer. Que esté vacío es buena señal."
/>
<details v-for="e in eventos" :key="e.ID" class="p-3">
<summary class="cursor-pointer flex items-center gap-2 text-sm">
<span class="px-1.5 py-0.5 rounded text-xs shrink-0" :class="nivelColor(e.nivel)">{{ e.nivel }}</span>
<span class="text-tenue text-xs shrink-0">{{ e.origen }}</span>
<span class="text-texto truncate">{{ e.mensaje }}</span>
<span class="text-tenue text-xs ml-auto shrink-0">{{ formatearFecha(e.CreatedAt) }}</span>
</summary>
<pre v-if="e.detalle" class="mt-2 bg-elevado border border-borde rounded-lg p-2 text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap">{{ e.detalle }}</pre>
</details>
</div>
</div>
</template>
@@ -0,0 +1,232 @@
<script setup>
import { computed, ref } from 'vue'
import { api } from '../../lib/api.js'
import { apiUmind } from '../../lib/contexto.js'
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
import UiIcono from '../../components/ui/UiIcono.vue'
const props = defineProps({
agenteId: { type: Number, required: true },
siteKey: { type: String, default: '' },
canales: { type: Array, default: () => [] },
})
const emit = defineEmits(['recargar', 'error'])
const showCanalForm = ref(false)
const canalForm = ref(canalVacio())
const widgetCopiado = ref(false)
// El reporte se descarga con una navegación normal (no fetch): así el
// navegador maneja el archivo y la cookie de sesión viaja sola.
const widgetSnippet = computed(() => {
const siteKey = props.siteKey || 'TU_SITE_KEY'
return `<script src="${window.location.origin}/widget/umind.js" data-site="${siteKey}" defer><\/script>`
})
async function copiarWidget() {
try {
await navigator.clipboard.writeText(widgetSnippet.value)
widgetCopiado.value = true
setTimeout(() => (widgetCopiado.value = false), 2000)
} catch {
error.value = 'No se pudo copiar automáticamente — seleccioná el texto y copialo a mano.'
}
}
function canalVacio() {
return {
tipo: 'telegram', bot_token: '', phone_number_id: '', access_token: '', app_secret: '', verify_token: '',
usar_whisper_audio: false, usar_ocr_imagenes: false, usar_archivos_docs: false,
}
}
function nuevoCanal() {
canalForm.value = canalVacio()
showCanalForm.value = true
}
async function guardarCanal() {
const credenciales =
canalForm.value.tipo === 'telegram'
? { bot_token: canalForm.value.bot_token }
: {
phone_number_id: canalForm.value.phone_number_id,
access_token: canalForm.value.access_token,
app_secret: canalForm.value.app_secret,
verify_token: canalForm.value.verify_token,
}
try {
await api.post(apiUmind('/umind/canales'), {
agente_id: props.agenteId, tipo: canalForm.value.tipo, credenciales, activo: true,
usar_whisper_audio: canalForm.value.usar_whisper_audio, usar_ocr_imagenes: canalForm.value.usar_ocr_imagenes,
usar_archivos_docs: canalForm.value.usar_archivos_docs,
})
showCanalForm.value = false
emit('recargar')
} catch (e) {
emit('error', e.message)
}
}
// El PUT de canales manda los tres interruptores siempre: si alguno faltara, el
// backend lo tomaría como false y lo apagaría sin que nadie lo pidiera.
async function guardarInterruptores(c, cambios) {
await api.put(apiUmind(`/umind/canales/${c.ID}`), {
activo: c.activo,
credenciales: {},
usar_whisper_audio: c.usar_whisper_audio,
usar_ocr_imagenes: c.usar_ocr_imagenes,
usar_archivos_docs: c.usar_archivos_docs,
...cambios,
})
emit('recargar')
}
const toggleCanal = (c) => guardarInterruptores(c, { activo: !c.activo })
const toggleCanalWhisper = (c) => guardarInterruptores(c, { usar_whisper_audio: !c.usar_whisper_audio })
const toggleCanalOcr = (c) => guardarInterruptores(c, { usar_ocr_imagenes: !c.usar_ocr_imagenes })
const toggleCanalArchivos = (c) => guardarInterruptores(c, { usar_archivos_docs: !c.usar_archivos_docs })
async function eliminarCanal(c) {
if (!confirm(`¿Eliminar el canal ${c.tipo}?`)) return
await api.del(apiUmind(`/umind/canales/${c.ID}`))
emit('recargar')
}
</script>
<template>
<div>
<div class="card p-4 mb-4">
<div class="flex items-center justify-between mb-2">
<div>
<span class="font-medium text-texto">Web (widget)</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs badge-ok">
siempre activo
</span>
</div>
<button
class="text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
:class="widgetCopiado ? 'bg-green-600 text-white' : 'bg-brand hover:bg-brand-dark text-white'"
@click="copiarWidget"
>
<UiIcono v-if="widgetCopiado" nombre="ok" :tam="14" />
{{ widgetCopiado ? 'Copiado' : 'Copiar código' }}
</button>
</div>
<p class="label mb-2">
Pegá esto antes de <code class="bg-elevado px-1 rounded">&lt;/body&gt;</code> en las páginas de tu sitio.
</p>
<pre class="bg-elevado border border-borde rounded-lg p-2.5 text-xs text-texto overflow-x-auto"><code>{{ widgetSnippet }}</code></pre>
</div>
<div class="flex justify-end mb-4">
<button class="btn-primary" @click="nuevoCanal">
+ Nuevo canal
</button>
</div>
<div class="card divide-y divide-borde">
<UiEmptyState
v-if="canales.length === 0"
titulo="No está atendiendo en ningún lado"
detalle="Conectá WhatsApp o Telegram para que empiece a responderle a tus clientes. El widget de tu web funciona aparte, con la clave de sitio de arriba."
/>
<div v-for="c in canales" :key="c.ID" class="p-4">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-texto capitalize">{{ c.tipo }}</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
{{ c.activo ? 'activo' : 'inactivo' }}
</span>
</div>
<div class="flex gap-3 text-sm">
<button class="text-tenue hover:text-gray-800 dark:hover:text-gray-100" @click="toggleCanal(c)">
{{ c.activo ? 'Desactivar' : 'Activar' }}
</button>
<button class="text-red-500 hover:text-red-700" @click="eliminarCanal(c)">Eliminar</button>
</div>
</div>
<div class="flex gap-4 mt-2 text-xs">
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
<input type="checkbox" :checked="c.usar_whisper_audio" @change="toggleCanalWhisper(c)" class="rounded border-borde text-brand focus:ring-brand" />
Transcribir audios (Whisper)
</label>
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
<input type="checkbox" :checked="c.usar_ocr_imagenes" @change="toggleCanalOcr(c)" class="rounded border-borde text-brand focus:ring-brand" />
Leer texto de imágenes (OCR)
</label>
<label class="flex items-center gap-1.5 text-texto cursor-pointer">
<input type="checkbox" :checked="c.usar_archivos_docs" @change="toggleCanalArchivos(c)" class="rounded border-borde text-brand focus:ring-brand" />
Leer archivos adjuntos (PDF, Word, texto)
</label>
</div>
<p class="label mt-1 break-all">
Webhook: <code class="bg-elevado px-1 rounded">{{ c.webhook_url }}</code>
</p>
<p v-if="c.tipo === 'whatsapp'" class="text-xs text-tenue mt-1">
Registrá esta URL como "Callback URL" en Meta for Developers WhatsApp Configuration, con el mismo verify_token que pusiste acá.
</p>
<p v-if="c.ultimo_error" class="text-xs text-red-600 dark:text-red-400 mt-1">{{ c.ultimo_error }}</p>
</div>
</div>
<div v-if="showCanalForm" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showCanalForm = false">
<div class="card p-6 w-full max-w-md">
<h2 class="font-semibold text-texto mb-4">Nuevo canal</h2>
<form class="space-y-3" @submit.prevent="guardarCanal">
<div>
<label class="label">Tipo</label>
<select v-model="canalForm.tipo" class="input">
<option value="telegram">Telegram</option>
<option value="whatsapp">WhatsApp Business</option>
</select>
</div>
<template v-if="canalForm.tipo === 'telegram'">
<div>
<label class="label">Bot token (de @BotFather)</label>
<input v-model="canalForm.bot_token" type="password" required class="input" />
</div>
</template>
<template v-else>
<div>
<label class="label">Phone Number ID</label>
<input v-model="canalForm.phone_number_id" required class="input" />
</div>
<div>
<label class="label">Access Token</label>
<input v-model="canalForm.access_token" type="password" required class="input" />
</div>
<div>
<label class="label">App Secret</label>
<input v-model="canalForm.app_secret" type="password" required class="input" />
</div>
<div>
<label class="label">Verify Token (lo inventás vos, lo vas a usar en Meta)</label>
<input v-model="canalForm.verify_token" required class="input" />
</div>
</template>
<div class="flex flex-col gap-2 pt-1">
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_whisper_audio" class="rounded border-borde text-brand focus:ring-brand" />
Transcribir audios con Whisper
</label>
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_ocr_imagenes" class="rounded border-borde text-brand focus:ring-brand" />
Leer texto de imágenes con OCR
</label>
<label class="flex items-center gap-2 text-sm text-texto cursor-pointer">
<input type="checkbox" v-model="canalForm.usar_archivos_docs" class="rounded border-borde text-brand focus:ring-brand" />
Leer archivos adjuntos (PDF, Word, texto)
</label>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="btn-ghost" @click="showCanalForm = false">Cancelar</button>
<button type="submit" class="btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<!-- Conexiones (correo, OAuth) -->
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup>
import { ref } from 'vue'
import { api } from '../../lib/api.js'
import { apiUmind } from '../../lib/contexto.js'
import UiMascota from '../../components/ui/UiMascota.vue'
const props = defineProps({
agenteId: { type: Number, required: true },
})
// La sesión se arma una vez por montaje: cada prueba arranca con el historial
// limpio, que es lo que se quiere al probar un cambio de conocimiento.
const sessionId = `staff-preview-${Math.random().toString(36).slice(2)}`
const mensajes = ref([])
const entrada = ref('')
const enviando = ref(false)
async function enviar() {
const texto = entrada.value.trim()
if (!texto || enviando.value) return
entrada.value = ''
mensajes.value.push({ role: 'user', content: texto })
enviando.value = true
try {
const r = await api.post(apiUmind('/umind/chat'), {
agente_id: props.agenteId,
session_id: sessionId,
mensaje: texto,
})
mensajes.value.push({ role: 'assistant', content: r.respuesta })
} catch (e) {
mensajes.value.push({ role: 'assistant', content: `${e.message}` })
} finally {
enviando.value = false
}
}
</script>
<template>
<div class="card p-4 flex flex-col h-[28rem]">
<div class="flex-1 overflow-y-auto space-y-2 mb-3">
<p v-if="mensajes.length === 0" class="text-sm text-tenue">
Probá este agente tal cual lo va a ver un visitante usa la misma config de IA, las mismas herramientas y la misma base de conocimiento.
</p>
<div
v-for="(m, i) in mensajes"
:key="i"
class="max-w-[80%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap"
:class="m.role === 'user' ? 'bg-brand text-white ml-auto' : 'bg-elevado text-texto'"
>
{{ m.content }}
</div>
<div v-if="enviando" class="flex items-center gap-2">
<UiMascota estado="pensando" :tam="30" class="text-brand" />
<span class="text-xs text-tenue">Pensando</span>
</div>
</div>
<form class="flex gap-2" @submit.prevent="enviar">
<input v-model="entrada" placeholder="Escribí un mensaje de prueba..." class="flex-1 input" />
<button type="submit" :disabled="enviando" class="btn-primary disabled:opacity-50 transition-colors">
Enviar
</button>
</form>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup>
import { api } from '../../lib/api.js'
import { apiUmind } from '../../lib/contexto.js'
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
const props = defineProps({
agenteId: { type: Number, required: true },
conexiones: { type: Array, default: () => [] },
})
// El padre es el dueño de los datos del agente: acá se avisa que cambiaron y
// él recarga. Si cada pestaña mantuviera su propia copia, volver de una a otra
// mostraría estados distintos de lo mismo.
const emit = defineEmits(['recargar'])
function conectar(proveedor) {
// Navegación normal (no fetch): el backend redirige a Google/Microsoft.
window.location.href = apiUmind(`/umind/conexiones/conectar?agente_id=${props.agenteId}&proveedor=${proveedor}`)
}
async function desconectar(c) {
if (!confirm(`¿Desconectar la cuenta ${c.email || c.proveedor}?`)) return
await api.del(apiUmind(`/umind/conexiones/${c.ID}`))
emit('recargar')
}
</script>
<template>
<div>
<p class="label mb-4">
Conectá una cuenta de correo para que este agente pueda enviar y leer correo en su nombre.
Se soporta una cuenta activa a la vez.
</p>
<div class="flex gap-2 mb-4">
<button class="border border-borde hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('google')">
Conectar Google
</button>
<button class="border border-borde hover:border-brand text-sm font-medium px-4 py-2 rounded-lg text-gray-700 dark:text-gray-200 transition-colors" @click="conectar('microsoft')">
Conectar Outlook
</button>
</div>
<div class="card divide-y divide-borde">
<UiEmptyState
v-if="conexiones.length === 0"
titulo="Sin cuentas conectadas"
detalle="Conectando una cuenta de correo, el agente puede leer y responder mensajes con tu dirección."
/>
<div v-for="c in conexiones" :key="c.ID" class="p-4 flex items-center justify-between">
<div>
<span class="font-medium text-texto capitalize">{{ c.proveedor }}</span>
<span class="ml-2 text-sm text-tenue">{{ c.email }}</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-xs" :class="c.activo ? 'badge-ok' : 'badge-neutro'">
{{ c.activo ? 'activa' : 'inactiva' }}
</span>
</div>
<button class="text-red-500 hover:text-red-700 text-sm" @click="desconectar(c)">Desconectar</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,129 @@
<script setup>
import { computed, ref } from 'vue'
import { api } from '../../lib/api.js'
import { apiUmind } from '../../lib/contexto.js'
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
import UiIcono from '../../components/ui/UiIcono.vue'
const props = defineProps({
agenteId: { type: Number, required: true },
sesiones: { type: Array, default: () => [] },
})
// El historial se pide al abrir cada conversación, no de entrada: cargar todas
// las conversaciones completas para mostrar una lista sería traer de más.
const historial = ref([])
const sesionActiva = ref(null)
const cargandoHistorial = ref(false)
// El canal se deduce del prefijo de la sesión — es como los generan los
// canales (wa:, tg:) y el chat de prueba (staff-preview-). Lo demás es el
// widget de la web.
function canalDe(sessionId) {
const id = String(sessionId || '')
if (id.startsWith('wa:')) return { nombre: 'WhatsApp', icono: 'mensaje', clase: 'badge-ok' }
if (id.startsWith('tg:')) return { nombre: 'Telegram', icono: 'mensaje', clase: 'badge-neutro' }
if (id.startsWith('staff-preview')) return { nombre: 'Prueba', icono: 'escribir', clase: 'badge-alerta' }
return { nombre: 'Tu web', icono: 'sitio', clase: 'badge-neutro' }
}
// Las pruebas propias se separan de las conversaciones reales: mezcladas,
// inflan la lista y el dueño no sabe cuáles son clientes de verdad.
const verPruebas = ref(false)
const reales = computed(() => props.sesiones.filter((s) => !String(s.session_id).startsWith('staff-preview')))
const pruebas = computed(() => props.sesiones.filter((s) => String(s.session_id).startsWith('staff-preview')))
const visibles = computed(() => (verPruebas.value ? props.sesiones : reales.value))
function cuando(iso) {
if (!iso) return ''
const d = new Date(iso)
const hoy = new Date()
const dias = Math.floor((hoy.setHours(0, 0, 0, 0) - new Date(d).setHours(0, 0, 0, 0)) / 86400000)
const hora = new Date(iso).toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })
if (dias <= 0) return `hoy ${hora}`
if (dias === 1) return `ayer ${hora}`
if (dias < 7) return `hace ${dias} días`
return new Date(iso).toLocaleDateString('es', { day: 'numeric', month: 'short' })
}
async function verHistorial(sessionId) {
sesionActiva.value = sessionId
cargandoHistorial.value = true
try {
const r = await api.get(apiUmind(`/umind/historial?agente_id=${props.agenteId}&session_id=${sessionId}`))
historial.value = r.items || []
} finally {
cargandoHistorial.value = false
}
}
</script>
<template>
<div class="grid lg:grid-cols-[minmax(16rem,22rem)_1fr] gap-4 items-start">
<div class="card divide-y divide-borde max-h-[30rem] overflow-y-auto">
<UiEmptyState
v-if="visibles.length === 0 && pruebas.length === 0"
titulo="Nadie escribió todavía"
detalle="Cuando alguien le hable a tu asistente —por WhatsApp, Telegram o tu web— la conversación aparece acá, con lo que preguntó y lo que se le respondió."
/>
<UiEmptyState
v-else-if="visibles.length === 0"
estado="durmiendo"
titulo="Ningún cliente escribió todavía"
:detalle="`Hay ${pruebas.length} ${pruebas.length === 1 ? 'conversación de prueba tuya' : 'conversaciones de prueba tuyas'} — activá el filtro de abajo para verlas.`"
/>
<button
v-for="s in visibles"
:key="s.session_id"
class="w-full text-left p-3 hover:bg-elevado transition-colors"
:class="sesionActiva === s.session_id ? 'bg-gray-50 dark:bg-gray-800' : ''"
@click="verHistorial(s.session_id)"
>
<!-- Lo que abrió la conversación es el dato que importa: muestra qué
le preguntan de verdad al negocio. -->
<div class="text-sm text-texto line-clamp-2">
{{ s.primer_mensaje || '(sin mensaje)' }}
</div>
<div class="flex items-center gap-2 mt-1.5 text-xs text-tenue">
<span class="px-1.5 py-0.5 rounded" :class="canalDe(s.session_id).clase">
{{ canalDe(s.session_id).nombre }}
</span>
<span class="tabular-nums">{{ s.mensajes }} msj</span>
<span class="ml-auto tabular-nums">{{ cuando(s.ultimo) }}</span>
</div>
</button>
<label
v-if="pruebas.length"
class="flex items-center gap-2 p-3 text-xs text-tenue cursor-pointer select-none"
>
<input v-model="verPruebas" type="checkbox" class="rounded border-borde text-brand focus:ring-brand" />
{{ pruebas.length === 1 ? 'Mostrar mi prueba' : `Mostrar mis ${pruebas.length} pruebas` }}
</label>
</div>
<div class="card p-4 max-h-[30rem] overflow-y-auto">
<UiEmptyState
v-if="!sesionActiva"
estado="normal"
titulo="Elegí una conversación"
detalle="Vas a ver el intercambio completo: lo que escribió la persona y lo que contestó tu asistente. Si una respuesta no te gusta, corregí su información en «Lo que sabe»."
/>
<p v-else-if="cargandoHistorial" class="text-sm text-tenue">Cargando</p>
<div v-else class="space-y-2">
<div v-for="m in historial" :key="m.ID" :class="m.role === 'user' ? 'text-right' : ''">
<div
class="inline-block max-w-[80%] px-3 py-2 rounded-lg text-sm text-left whitespace-pre-wrap"
:class="m.role === 'user' ? 'bg-brand text-white' : 'bg-elevado text-texto'"
>
{{ m.content }}
</div>
<div class="text-[10px] text-tenue mt-0.5 tabular-nums" :class="m.role === 'user' ? 'pr-1' : 'pl-1'">
{{ cuando(m.CreatedAt) }}
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,188 @@
<script setup>
import { ref } from 'vue'
import { api } from '../../lib/api.js'
import { apiUmind } from '../../lib/contexto.js'
import UiEmptyState from '../../components/ui/UiEmptyState.vue'
import UiIcono from '../../components/ui/UiIcono.vue'
const props = defineProps({
agenteId: { type: Number, required: true },
tools: { type: Array, default: () => [] },
})
// El padre es dueño de la lista: acá se avisa que cambió y él la recarga.
const emit = defineEmits(['recargar', 'error'])
const showToolForm = ref(false)
const editingTool = ref(null)
const toolForm = ref(toolVacio())
function toolVacio() {
return {
nombre: '', descripcion: '', url: '', auth_header_nombre: '', auth_header_valor: '',
tocarAuth: false, parametros: [], activa: true,
}
}
function nuevaTool() {
editingTool.value = null
toolForm.value = toolVacio()
showToolForm.value = true
}
function editarTool(t) {
editingTool.value = t
let parametros = []
try {
parametros = JSON.parse(t.parametros_json || '[]') || []
} catch {
parametros = []
}
toolForm.value = {
nombre: t.nombre, descripcion: t.descripcion, url: t.url,
auth_header_nombre: t.auth_header_nombre, auth_header_valor: '', tocarAuth: false,
parametros, activa: t.activa,
}
showToolForm.value = true
}
function agregarParametro() {
toolForm.value.parametros.push({ nombre: '', tipo: 'string', descripcion: '', requerido: false })
}
function quitarParametro(i) {
toolForm.value.parametros.splice(i, 1)
}
async function guardarTool() {
const payload = {
agente_id: props.agenteId,
nombre: toolForm.value.nombre.trim(),
descripcion: toolForm.value.descripcion,
url: toolForm.value.url.trim(),
auth_header_nombre: toolForm.value.auth_header_nombre,
parametros: toolForm.value.parametros,
activa: toolForm.value.activa,
}
if (toolForm.value.tocarAuth) {
payload.auth_header_valor = toolForm.value.auth_header_valor
}
try {
if (editingTool.value) {
await api.put(apiUmind(`/umind/tools/${editingTool.value.ID}`), payload)
} else {
await api.post(apiUmind('/umind/tools'), payload)
}
showToolForm.value = false
emit('recargar')
} catch (e) {
emit('error', e.message)
}
}
async function eliminarTool(t) {
if (!confirm(`¿Eliminar la herramienta "${t.nombre}"?`)) return
await api.del(apiUmind(`/umind/tools/${t.ID}`))
emit('recargar')
}
</script>
<template>
<div>
<div class="flex justify-between items-center mb-4">
<p class="label">Máximo 10 herramientas activas por agente.</p>
<button class="btn-primary" @click="nuevaTool">
+ Nueva herramienta
</button>
</div>
<div class="card divide-y divide-borde">
<UiEmptyState
v-if="tools.length === 0"
titulo="Sin herramientas conectadas"
detalle="Las herramientas le dejan consultar tus sistemas mientras conversa: stock, estado de un pedido, disponibilidad de turnos. Sin ninguna, responde solo con lo que tiene cargado."
/>
<div v-for="t in tools" :key="t.ID" class="p-4 flex items-center justify-between">
<div>
<div class="text-sm text-texto font-mono">{{ t.nombre }}</div>
<div class="label mt-0.5">{{ t.descripcion }}</div>
<div class="text-xs text-tenue mt-0.5">
{{ t.url }}
<span v-if="t.auth_configurado" class="ml-1 text-green-600 dark:text-green-400">· auth configurada</span>
<span v-if="!t.activa" class="ml-1 text-gray-400">· inactiva</span>
</div>
</div>
<div class="flex gap-3 text-sm shrink-0">
<button class="text-tenue hover:text-gray-800 dark:hover:text-gray-100" @click="editarTool(t)">Editar</button>
<button class="text-red-500 hover:text-red-700" @click="eliminarTool(t)">Eliminar</button>
</div>
</div>
</div>
<div v-if="showToolForm" class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" @click.self="showToolForm = false">
<div class="card p-6 w-full max-w-xl max-h-[85vh] overflow-y-auto">
<h2 class="font-semibold text-texto mb-4">{{ editingTool ? 'Editar herramienta' : 'Nueva herramienta' }}</h2>
<form class="space-y-3" @submit.prevent="guardarTool">
<div>
<label class="label">Nombre (identificador, ej: consultar_stock)</label>
<input v-model="toolForm.nombre" required pattern="[a-z][a-z0-9_]{2,63}" class="input font-mono" />
</div>
<div>
<label class="label">Descripción (esto lo lee el modelo para decidir cuándo usarla)</label>
<textarea v-model="toolForm.descripcion" rows="2" required class="input"></textarea>
</div>
<div>
<label class="label">URL del webhook (https)</label>
<input v-model="toolForm.url" type="url" required placeholder="https://..." class="input" />
</div>
<div class="border border-borde rounded-lg p-3 space-y-2">
<div class="flex items-center justify-between">
<label class="label">Parámetros que completa el modelo</label>
<button type="button" class="text-xs text-brand" @click="agregarParametro">+ agregar</button>
</div>
<div v-for="(p, i) in toolForm.parametros" :key="i" class="flex gap-2 items-center">
<input v-model="p.nombre" placeholder="nombre" class="flex-1 border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs font-mono" />
<select v-model="p.tipo" class="border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs">
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
<input v-model="p.descripcion" placeholder="descripción" class="flex-1 border border-borde bg-white dark:bg-gray-800 text-texto rounded px-2 py-1 text-xs" />
<label class="label flex items-center gap-1">
<input v-model="p.requerido" type="checkbox" /> req.
</label>
<button type="button" class="text-red-400 text-xs" @click="quitarParametro(i)"><UiIcono nombre="cerrar" :tam="13" /></button>
</div>
<p v-if="toolForm.parametros.length === 0" class="text-xs text-gray-400">Sin parámetros.</p>
</div>
<div class="border border-borde rounded-lg p-3 space-y-2">
<label class="label">Autenticación saliente (opcional)</label>
<input v-model="toolForm.auth_header_nombre" placeholder="Nombre del header, ej: Authorization" class="input" />
<label class="flex items-center gap-2 label">
<input v-model="toolForm.tocarAuth" type="checkbox" />
{{ editingTool ? 'Cambiar el valor del secreto' : 'Configurar valor' }}
</label>
<input
v-if="toolForm.tocarAuth"
v-model="toolForm.auth_header_valor"
type="password"
placeholder="Valor del header (ej: Bearer xxxx)"
class="input"
/>
</div>
<label class="flex items-center gap-2 text-sm text-texto">
<input v-model="toolForm.activa" type="checkbox" /> Activa
</label>
<div class="flex justify-end gap-2 pt-2">
<button type="button" class="btn-ghost" @click="showToolForm = false">Cancelar</button>
<button type="submit" class="btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<!-- Canales -->
</template>
+91 -24
View File
@@ -19,7 +19,7 @@ type AiConfig struct {
ApiKey string `gorm:"type:text;not null" json:"api_key"` // Clave de API ApiKey string `gorm:"type:text;not null" json:"api_key"` // Clave de API
BaseURL string `gorm:"type:text" json:"base_url"` // URL base (override), vacío = default del provider BaseURL string `gorm:"type:text" json:"base_url"` // URL base (override), vacío = default del provider
ModelName string `gorm:"size:100" json:"model_name"` // ej: qwen2.5-72b-instruct ModelName string `gorm:"size:100" json:"model_name"` // ej: qwen2.5-72b-instruct
IsActive bool `gorm:"default:true" json:"is_active"` // Solo uno activo a la vez IsActive bool `gorm:"default:true" json:"is_active"` // varias pueden estar activas: una por módulo
Notes string `gorm:"type:text" json:"notes"` Notes string `gorm:"type:text" json:"notes"`
// Modulo indica a qué servicio pertenece esta config. // Modulo indica a qué servicio pertenece esta config.
// "" = global (disponible para todos como fallback) // "" = global (disponible para todos como fallback)
@@ -108,21 +108,6 @@ func GetAiConfigByID(id uint, out *AiConfig) error {
return app.Http.Database.DB.First(out, id).Error return app.Http.Database.DB.First(out, id).Error
} }
// GetActiveAiConfig retorna la primera configuración activa del provider indicado.
// Si provider está vacío, retorna cualquier config activa.
func GetActiveAiConfig(provider string) (*AiConfig, error) {
var item AiConfig
db := app.Http.Database.DB.Where("is_active = ?", true)
if provider != "" {
db = db.Where("provider = ?", provider)
}
if err := db.First(&item).Error; err != nil {
log.Printf("[AI_CONFIG] No se encontró config activa para provider '%s': %v", provider, err)
return nil, err
}
return &item, nil
}
// SplitModulos parte el campo Modulo (comma-separated) en un slice limpio. // SplitModulos parte el campo Modulo (comma-separated) en un slice limpio.
// "" → [] (config global), "landing,query_runner" → ["landing","query_runner"] // "" → [] (config global), "landing,query_runner" → ["landing","query_runner"]
func SplitModulos(modulo string) []string { func SplitModulos(modulo string) []string {
@@ -171,10 +156,40 @@ func GetAiConfigSelectPorTenants(tenantIDs []uint) ([]AiConfig, error) {
return items, nil return items, nil
} }
// GetAiConfigsPorTenant lista las configs que cargó un cliente para su espacio.
// No incluye las globales del staff a propósito: el cliente las puede usar,
// pero no administrarlas, y mezclarlas acá invitaría a intentarlo.
func GetAiConfigsPorTenant(tenantID uint) ([]AiConfig, error) {
var items []AiConfig
err := app.Http.Database.DB.
Where("tenant_id = ?", tenantID).
Order("nombre ASC").Find(&items).Error
return items, err
}
// ContarAgentesConAiConfig dice cuántos agentes dependen de una config. Sin
// esto, borrarla los dejaría apuntando a algo inexistente y cayendo al
// proveedor global sin que nadie se entere.
func ContarAgentesConAiConfig(aiConfigID uint) (int64, error) {
var n int64
err := app.Http.Database.DB.Model(&UmindAgente{}).
Where("ai_config_id = ?", aiConfigID).Count(&n).Error
return n, err
}
// QuitarAgenteBotSalvo deja como cerebro del agente solo a la config indicada.
// GetAgenteBotAiConfig hace First() sobre es_agente_bot: con dos marcadas, cuál
// gana depende del orden de la tabla, que no es una forma de elegir nada.
func QuitarAgenteBotSalvo(id uint) {
app.Http.Database.DB.Model(&AiConfig{}).
Where("id <> ? AND es_agente_bot = ?", id, true).
Update("es_agente_bot", false)
}
// GetAgenteBotConfig retorna la config marcada como agente Telegram, con su TelegramConfig cargada. // GetAgenteBotConfig retorna la config marcada como agente Telegram, con su TelegramConfig cargada.
func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) { func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
var ai AiConfig var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil { if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ? AND tenant_id IS NULL", true, true).First(&ai).Error; err != nil {
return nil, nil, fmt.Errorf("no hay agente bot configurado: %w", err) return nil, nil, fmt.Errorf("no hay agente bot configurado: %w", err)
} }
if ai.TelegramConfigID == nil { if ai.TelegramConfigID == nil {
@@ -192,7 +207,7 @@ func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) {
// asignado. La usa el chat propio del dashboard para compartir el mismo motor. // asignado. La usa el chat propio del dashboard para compartir el mismo motor.
func GetAgenteBotAiConfig() (*AiConfig, error) { func GetAgenteBotAiConfig() (*AiConfig, error) {
var ai AiConfig var ai AiConfig
if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ?", true, true).First(&ai).Error; err != nil { if err := app.Http.Database.DB.Where("es_agente_bot = ? AND is_active = ? AND tenant_id IS NULL", true, true).First(&ai).Error; err != nil {
return nil, fmt.Errorf("no hay agente configurado: %w", err) return nil, fmt.Errorf("no hay agente configurado: %w", err)
} }
return &ai, nil return &ai, nil
@@ -205,7 +220,15 @@ func GetAgenteBotAiConfig() (*AiConfig, error) {
// 3. Cualquier config activa (último recurso) // 3. Cualquier config activa (último recurso)
func GetAiConfigForService(service string) (*AiConfig, error) { func GetAiConfigForService(service string) (*AiConfig, error) {
var items []AiConfig var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ?", true).Order("id ASC").Find(&items).Error; err != nil { // tenant_id IS NULL: las configs de un cliente son SUYAS y solo las usa su
// agente. Sin este filtro, la del cliente que no tiene módulo asignado
// —ninguna lo tiene— caía en el fallback global y terminaba atendiendo
// tareas nuestras: clasificar correos de soporte, importar plantillas, la
// vCard. Es decir, su cuenta pagando nuestro trabajo, sin que se entere
// ninguno de los dos.
if err := app.Http.Database.DB.
Where("is_active = ? AND tenant_id IS NULL", true).
Order("id ASC").Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err) return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
} }
if len(items) == 0 { if len(items) == 0 {
@@ -230,9 +253,48 @@ func GetAiConfigForService(service string) (*AiConfig, error) {
} }
} }
// 3. Cualquier config activa como último recurso // 3. Cualquier config activa como último recurso, pero nunca una que esté
log.Printf("[AI_CONFIG] No se encontró config para servicio '%s', usando cualquier activa", service) // dedicada a un servicio que no sabe conversar: la de embeddings devuelve
return &items[0], nil // vectores y la de Whisper transcribe audio. Caer ahí daba errores del
// proveedor imposibles de relacionar con esta elección.
for i := range items {
if esConfigDeUsoEspecial(items[i].Modulo) {
continue
}
log.Printf("[AI_CONFIG] Sin config para %q, se usa %q (que no la declara)", service, items[i].Nombre)
return &items[i], nil
}
return nil, fmt.Errorf("no hay ninguna configuración de IA para %q: asignale ese módulo a una config en /app/ai-config", service)
}
// esConfigDeUsoEspecial marca los módulos cuyo endpoint no es de chat, así que
// no sirven como comodín para otra cosa.
func esConfigDeUsoEspecial(modulo string) bool {
for _, m := range SplitModulos(modulo) {
if m == "whisper" || m == "umind_embeddings" {
return true
}
}
return false
}
// HayAiConfigParaModulo dice si alguna config activa declara ese módulo.
// Sirve para elegir un módulo propio solo cuando el admin lo configuró, y si no
// caer al que se venía usando — GetAiConfigForService no lo distingue porque
// tiene fallback a la global y a cualquier activa.
func HayAiConfigParaModulo(modulo string) bool {
var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
return false
}
for i := range items {
for _, m := range SplitModulos(items[i].Modulo) {
if m == modulo {
return true
}
}
}
return false
} }
// GetWhisperConfig retorna la config de IA activa etiquetada específicamente con // GetWhisperConfig retorna la config de IA activa etiquetada específicamente con
@@ -242,7 +304,8 @@ func GetAiConfigForService(service string) (*AiConfig, error) {
// transcribir contra un proveedor que no soporta ese endpoint (ej. Anthropic). // transcribir contra un proveedor que no soporta ese endpoint (ej. Anthropic).
func GetWhisperConfig() (*AiConfig, error) { func GetWhisperConfig() (*AiConfig, error) {
var items []AiConfig var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ?", true).Find(&items).Error; err != nil { // Solo configs del staff, por lo mismo que GetAiConfigForService.
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err) return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
} }
for i := range items { for i := range items {
@@ -265,7 +328,11 @@ func GetWhisperConfig() (*AiConfig, error) {
// en vez de caer a cualquier config activa. // en vez de caer a cualquier config activa.
func GetUmindEmbeddingsConfig() (*AiConfig, error) { func GetUmindEmbeddingsConfig() (*AiConfig, error) {
var items []AiConfig var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ?", true).Find(&items).Error; err != nil { // Global obligatoriamente: los vectores de todos los agentes tienen que
// salir del mismo modelo o la similitud coseno entre ellos no significa
// nada. Un cliente con su propio modelo de embeddings rompería su propia
// búsqueda sin ningún error visible.
if err := app.Http.Database.DB.Where("is_active = ? AND tenant_id IS NULL", true).Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err) return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
} }
for i := range items { for i := range items {
+68
View File
@@ -0,0 +1,68 @@
package models
import (
"os"
"strings"
"testing"
)
// El fallback de GetAiConfigForService puede terminar usando una config que no
// declara el servicio pedido. Lo que no puede es agarrar una dedicada a
// embeddings o a Whisper: esos endpoints no conversan, y el error del proveedor
// no se parece en nada a la causa real.
func TestConfigsDeUsoEspecialNoSirvenDeComodin(t *testing.T) {
casos := map[string]bool{
"whisper": true,
"umind_embeddings": true,
"landing,umind_embeddings": true,
"": false,
"ia": false,
"landing,query_runner": false,
}
for modulo, want := range casos {
if got := esConfigDeUsoEspecial(modulo); got != want {
t.Errorf("esConfigDeUsoEspecial(%q) = %v, want %v", modulo, got, want)
}
}
}
// La consulta de todo resolvedor global tiene que excluir las configs de
// cliente. Sin ese filtro, la config que carga un cliente —que no lleva módulo,
// ninguna lo lleva— cae en el fallback y pasa a atender tareas nuestras:
// clasificar correos de soporte, importar plantillas, la vCard. Su cuenta
// pagando nuestro trabajo, y sin que se entere ninguno de los dos.
//
// Se verifica sobre el código porque son consultas a base: acá no hay una.
func TestLosResolvedoresGlobalesExcluyenConfigsDeCliente(t *testing.T) {
fuente, err := os.ReadFile("ai_config.go")
if err != nil {
t.Fatal(err)
}
texto := string(fuente)
// Toda función que resuelve una config para uso del sistema.
resolvedores := []string{
"func GetAiConfigForService(",
"func GetWhisperConfig(",
"func GetUmindEmbeddingsConfig(",
"func GetAgenteBotAiConfig(",
"func GetAgenteBotConfig(",
"func HayAiConfigParaModulo(",
}
for _, firma := range resolvedores {
i := strings.Index(texto, firma)
if i < 0 {
t.Errorf("no encontré %s — ¿se renombró?", firma)
continue
}
// El cuerpo hasta la próxima función de nivel superior.
resto := texto[i+len(firma):]
if j := strings.Index(resto, "\nfunc "); j > 0 {
resto = resto[:j]
}
if !strings.Contains(resto, "tenant_id IS NULL") {
t.Errorf("%s no filtra tenant_id IS NULL: puede devolver la config de un cliente para una tarea del sistema", firma)
}
}
}
+36 -14
View File
@@ -9,19 +9,26 @@ import (
type ProyectoTicket struct { type ProyectoTicket struct {
gorm.Model gorm.Model
ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"` ProyectoID *uint `json:"proyecto_id" gorm:"column:proyecto_id;index"`
PortalUserID *uint `json:"portal_user_id" gorm:"column:portal_user_id;index"` PortalUserID *uint `json:"portal_user_id" gorm:"column:portal_user_id;index"`
AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"` AutorNombre string `json:"autor_nombre" gorm:"column:autor_nombre"`
EmailFrom string `json:"email_from" gorm:"column:email_from;size:255"` EmailFrom string `json:"email_from" gorm:"column:email_from;size:255"`
Titulo string `json:"titulo" gorm:"column:titulo"` Titulo string `json:"titulo" gorm:"column:titulo"`
Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"` Descripcion string `json:"descripcion" gorm:"column:descripcion;type:text"`
Estado string `json:"estado" gorm:"column:estado;default:'abierto'"` // abierto|en_progreso|resuelto|cerrado Estado string `json:"estado" gorm:"column:estado;default:'abierto'"` // abierto|en_progreso|resuelto|cerrado
Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` // baja|media|alta|urgente Prioridad string `json:"prioridad" gorm:"column:prioridad;default:'media'"` // baja|media|alta|urgente
Origen string `json:"origen" gorm:"column:origen;default:'portal'"` // portal|email Origen string `json:"origen" gorm:"column:origen;default:'portal'"` // portal|email
AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"` AsignadoA *uint `json:"asignado_a" gorm:"column:asignado_a;index"`
Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoA"` Asignado *Users `json:"asignado" gorm:"foreignKey:AsignadoA"`
MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo que originó el ticket (dedup) MessageID string `json:"message_id" gorm:"column:message_id;size:255;index"` // Message-Id del correo que originó el ticket (dedup)
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"` // ClienteID se resuelve al crear el ticket a partir del remitente. Si queda
// en nil el que escribió no está registrado: es un contacto externo, y eso
// también es información (no hay un campo aparte para "externo", es esto).
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
Cliente *Cliente `json:"cliente" gorm:"foreignKey:ClienteID"`
// Categoria la pone el clasificador: error | facturacion | acceso | consulta | otro
Categoria string `json:"categoria" gorm:"column:categoria;size:40;index"`
Mensajes []TicketMensaje `json:"mensajes" gorm:"foreignKey:TicketID"`
} }
func (ProyectoTicket) TableName() string { return "proyecto_tickets" } func (ProyectoTicket) TableName() string { return "proyecto_tickets" }
@@ -105,7 +112,7 @@ func GetTicketsByPortalUser(portalUserID uint) ([]ProyectoTicket, error) {
func GetAllTickets(estado string) ([]ProyectoTicket, error) { func GetAllTickets(estado string) ([]ProyectoTicket, error) {
var items []ProyectoTicket var items []ProyectoTicket
db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Order("created_at DESC") db := app.Http.Database.DB.Preload("Mensajes").Preload("Asignado").Preload("Cliente").Order("created_at DESC")
if estado != "" && estado != "todos" { if estado != "" && estado != "todos" {
db = db.Where("estado = ?", estado) db = db.Where("estado = ?", estado)
} }
@@ -142,3 +149,18 @@ func MarkTicketMessagesReadByPortal(ticketID uint) error {
Where("ticket_id = ? AND es_admin = true AND leido_portal = false", ticketID). Where("ticket_id = ? AND es_admin = true AND leido_portal = false", ticketID).
Update("leido_portal", true).Error Update("leido_portal", true).Error
} }
// GetClientePorEmail busca un cliente por su dirección de correo, exacta y sin
// distinguir mayúsculas. A propósito no se busca por dominio: con gmail.com o
// hotmail.com de por medio, adivinar por dominio ata tickets al cliente
// equivocado.
func GetClientePorEmail(email string) (*Cliente, error) {
var c Cliente
err := app.Http.Database.DB.
Where("LOWER(email) = LOWER(?) OR LOWER(email_cc) = LOWER(?)", email, email).
First(&c).Error
if err != nil {
return nil, err
}
return &c, nil
}
+49 -15
View File
@@ -7,23 +7,51 @@ import (
type SoporteWebhookConfig struct { type SoporteWebhookConfig struct {
gorm.Model gorm.Model
Activo bool `json:"activo" gorm:"column:activo;default:true"` Activo bool `json:"activo" gorm:"column:activo;default:true"`
Nombre string `json:"nombre" gorm:"column:nombre;size:100"` Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic Provider string `json:"provider" gorm:"column:provider;size:50;default:'sendgrid'"` // sendgrid|mailgun|generic
ApiKey string `json:"api_key" gorm:"column:api_key;size:255"` ApiKey string `json:"api_key" gorm:"column:api_key;size:255"`
EmailDestino string `json:"email_destino" gorm:"column:email_destino;size:255"` // ej: soporte@u-s.app EmailDestino string `json:"email_destino" gorm:"column:email_destino;size:255"` // ej: soporte@u-s.app
ResponderAuto bool `json:"responder_auto" gorm:"column:responder_auto;default:true"` ResponderAuto bool `json:"responder_auto" gorm:"column:responder_auto;default:true"`
MensajeAuto string `json:"mensaje_auto" gorm:"column:mensaje_auto;type:text"` MensajeAuto string `json:"mensaje_auto" gorm:"column:mensaje_auto;type:text"`
AsignarA *uint `json:"asignar_a" gorm:"column:asignar_a;index"` // auto-asignar tickets a este user AsignarA *uint `json:"asignar_a" gorm:"column:asignar_a;index"` // auto-asignar tickets a este user
// SMTP salida para notificaciones y auto-respuesta // SMTP salida para notificaciones y auto-respuesta
SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"` SmtpHost string `json:"smtp_host" gorm:"column:smtp_host;size:255"`
SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"` SmtpPort int `json:"smtp_port" gorm:"column:smtp_port;default:587"`
SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"` SmtpUsername string `json:"smtp_username" gorm:"column:smtp_username;size:255"`
SmtpPassword string `json:"smtp_password" gorm:"column:smtp_password;size:255"` // cifrado AES SmtpPassword string `json:"smtp_password" gorm:"column:smtp_password;size:255"` // cifrado AES
SmtpEncryption string `json:"smtp_encryption" gorm:"column:smtp_encryption;size:20;default:'tls'"` // tls|starttls|none SmtpEncryption string `json:"smtp_encryption" gorm:"column:smtp_encryption;size:20;default:'tls'"` // tls|starttls|none
SmtpFromAddr string `json:"smtp_from_addr" gorm:"column:smtp_from_addr;size:255"` SmtpFromAddr string `json:"smtp_from_addr" gorm:"column:smtp_from_addr;size:255"`
SmtpFromName string `json:"smtp_from_name" gorm:"column:smtp_from_name;size:255"` SmtpFromName string `json:"smtp_from_name" gorm:"column:smtp_from_name;size:255"`
// IMAP entrante: leer el buzón directamente en vez de depender de que un
// proveedor nos haga POST. Es lo único que hace falta para responderle a un
// cliente que escribe a soporte@ desde su correo de siempre.
ImapActivo bool `json:"imap_activo" gorm:"column:imap_activo;default:false"`
ImapHost string `json:"imap_host" gorm:"column:imap_host;size:255"`
ImapPort int `json:"imap_port" gorm:"column:imap_port;default:993"`
ImapUsername string `json:"imap_username" gorm:"column:imap_username;size:255"`
ImapPasswordEnc string `json:"-" gorm:"column:imap_password_enc;type:text"` // AES-GCM con APP_KEY
ImapEncryption string `json:"imap_encryption" gorm:"column:imap_encryption;size:20;default:'ssl'"` // ssl|starttls
ImapCarpeta string `json:"imap_carpeta" gorm:"column:imap_carpeta;size:100;default:'INBOX'"`
// Ventana hacia atrás, en horas: solo se leen los correos recibidos dentro
// de ella. 0 = sin límite (todo el buzón sin leer). Es lo que evita que la
// primera corrida se coma años de correo viejo.
ImapHorasAtras int `json:"imap_horas_atras" gorm:"column:imap_horas_atras;default:12"`
// Filtro con IA: no todo lo que llega al buzón es soporte (newsletters,
// notificaciones de bancos, facturas de proveedores). Si está prendido, se
// clasifica cada correo nuevo antes de abrir ticket.
ClasificarConIA bool `json:"clasificar_con_ia" gorm:"column:clasificar_con_ia;default:false"`
ContextoNegocio string `json:"contexto_negocio" gorm:"column:contexto_negocio;type:text"`
// Agente de uMind cuya base de conocimiento se usa para redactar borradores
// de respuesta. Sin agente el borrador se arma solo con la conversación.
AgenteBorradorID *uint `json:"agente_borrador_id" gorm:"column:agente_borrador_id;index"`
// Solo para la vista: dice si ya hay contraseña guardada sin exponerla, para
// que el formulario sepa que puede mandar el campo vacío sin borrarla.
TieneImapPassword bool `json:"tiene_imap_password" gorm:"-"`
} }
func (SoporteWebhookConfig) TableName() string { return "soporte_webhook_config" } func (SoporteWebhookConfig) TableName() string { return "soporte_webhook_config" }
@@ -42,7 +70,13 @@ func GetAllSoporteWebhookConfigs() ([]SoporteWebhookConfig, error) {
func SaveSoporteWebhookConfig(s *SoporteWebhookConfig) error { func SaveSoporteWebhookConfig(s *SoporteWebhookConfig) error {
if s.ID > 0 { if s.ID > 0 {
return app.Http.Database.DB.Model(s).Updates(s).Error // Select("*") para que los booleanos en false (activo, responder_auto,
// imap_activo) también se guarden: Updates con struct ignora los ceros,
// así que desactivar algo no tenía efecto.
return app.Http.Database.DB.Model(&SoporteWebhookConfig{}).
Where("id = ?", s.ID).
Select("*").Omit("id", "created_at", "deleted_at").
Updates(s).Error
} }
return app.Http.Database.DB.Create(s).Error return app.Http.Database.DB.Create(s).Error
} }
+106 -21
View File
@@ -31,6 +31,23 @@ type UmindTenant struct {
// el staff los asigne, y el ALTER TABLE no falla sobre datos existentes. // el staff los asigne, y el ALTER TABLE no falla sobre datos existentes.
ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"` ClienteID *uint `json:"cliente_id" gorm:"column:cliente_id;index"`
PlanID *uint `json:"plan_id" gorm:"column:plan_id;index"` PlanID *uint `json:"plan_id" gorm:"column:plan_id;index"`
// UltimoAvisoTope guarda el período ("2026-08") en que se avisó que se
// pasó el tope de consumo, para no repetirlo por cada mensaje. En la base
// y no en memoria: con despliegues varias veces al día, un flag en RAM se
// borra seguido y el cliente recibe el mismo aviso una y otra vez.
UltimoAvisoTope string `json:"ultimo_aviso_tope" gorm:"column:ultimo_aviso_tope;size:7"`
}
// MarcarAvisoTope deja registrado que ya se avisó en ese período. Devuelve
// false si el aviso ya estaba puesto, que es la señal de no volver a mandarlo.
//
// El UPDATE condicionado es lo que hace la operación atómica: si dos mensajes
// cruzan el tope a la vez, sólo uno afecta una fila y sólo ese avisa.
func MarcarAvisoTope(tenantID uint, periodo string) bool {
res := app.Http.Database.DB.Model(&UmindTenant{}).
Where("id = ? AND (ultimo_aviso_tope IS NULL OR ultimo_aviso_tope <> ?)", tenantID, periodo).
Update("ultimo_aviso_tope", periodo)
return res.Error == nil && res.RowsAffected > 0
} }
func (UmindTenant) TableName() string { return "umind_tenants" } func (UmindTenant) TableName() string { return "umind_tenants" }
@@ -131,11 +148,38 @@ func (t *UmindTenant) DominioPermitido(host string) bool {
type UmindDocumento struct { type UmindDocumento struct {
gorm.Model gorm.Model
AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"` AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"`
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // url | archivo Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // url | archivo | texto
Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, o el nombre del archivo Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, el nombre del archivo, o el título del texto
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente | procesando | listo | error Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente | procesando | listo | error
Error string `json:"error" gorm:"column:error;type:text"` Error string `json:"error" gorm:"column:error;type:text"`
TotalChunks int `json:"total_chunks" gorm:"column:total_chunks;default:0"` TotalChunks int `json:"total_chunks" gorm:"column:total_chunks;default:0"`
// Contenido guarda el texto de las fuentes que no se pueden volver a
// buscar solas (lo que escribió el dueño, lo que se extrajo de un archivo).
// Sin esto no se puede editar ni reprocesar sin volver a subir el archivo.
Contenido string `json:"contenido" gorm:"column:contenido;type:text"`
// MaxPaginas se guarda para poder recrawlear igual que la primera vez.
MaxPaginas int `json:"max_paginas" gorm:"column:max_paginas;default:0"`
// ProcesadoAt dice de cuándo es el conocimiento. Una web cambia y el agente
// sigue contestando lo viejo con total seguridad: esta fecha es lo único
// que delata que la fuente quedó vieja.
ProcesadoAt *time.Time `json:"procesado_at" gorm:"column:procesado_at"`
// AutoActualizar deja que el cron la vuelva a procesar sola.
AutoActualizar bool `json:"auto_actualizar" gorm:"column:auto_actualizar;default:false"`
}
// NombresDeTenantsUmind devuelve id → nombre para poder etiquetar cosas que
// solo guardan el id. Una consulta y no una por fila: son pocos y se usan para
// pintar una lista entera.
func NombresDeTenantsUmind() map[uint]string {
var filas []UmindTenant
out := map[uint]string{}
if err := app.Http.Database.DB.Select("id, nombre").Find(&filas).Error; err != nil {
return out
}
for _, f := range filas {
out[f.ID] = f.Nombre
}
return out
} }
func (UmindDocumento) TableName() string { return "umind_documentos" } func (UmindDocumento) TableName() string { return "umind_documentos" }
@@ -159,11 +203,32 @@ func GetUmindDocumentoByID(id uint) (*UmindDocumento, error) {
} }
func UpdateUmindDocumentoEstado(id uint, estado, errMsg string, totalChunks int) error { func UpdateUmindDocumentoEstado(id uint, estado, errMsg string, totalChunks int) error {
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(map[string]interface{}{ updates := map[string]interface{}{
"estado": estado, "estado": estado,
"error": errMsg, "error": errMsg,
"total_chunks": totalChunks, "total_chunks": totalChunks,
}).Error }
if estado == "listo" {
ahora := time.Now()
updates["procesado_at"] = &ahora
}
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(updates).Error
}
func UpdateUmindDocumento(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(updates).Error
}
// GetDocumentosParaRefrescar devuelve las fuentes con auto-actualización que no
// se procesan desde hace más de los días indicados.
func GetDocumentosParaRefrescar(dias int) ([]UmindDocumento, error) {
var items []UmindDocumento
corte := time.Now().AddDate(0, 0, -dias)
err := app.Http.Database.DB.
Where("auto_actualizar = ? AND estado <> ?", true, "procesando").
Where("procesado_at IS NULL OR procesado_at < ?", corte).
Find(&items).Error
return items, err
} }
func DeleteUmindDocumento(id uint) error { func DeleteUmindDocumento(id uint) error {
@@ -206,6 +271,13 @@ func EmbeddingFromJSON(s string) ([]float32, error) {
return v, nil return v, nil
} }
// BorrarChunksDeDocumento limpia los fragmentos de una fuente antes de volver a
// procesarla. Sin esto, reprocesar deja la versión vieja y la nueva compitiendo
// en la búsqueda, y la vieja puede ganar.
func BorrarChunksDeDocumento(documentoID uint) error {
return app.Http.Database.DB.Where("documento_id = ?", documentoID).Delete(&UmindChunk{}).Error
}
func CreateUmindChunks(chunks []UmindChunk) error { func CreateUmindChunks(chunks []UmindChunk) error {
if len(chunks) == 0 { if len(chunks) == 0 {
return nil return nil
@@ -254,23 +326,6 @@ func GetUmindHistorial(agenteID uint, sessionID string, n int) ([]UmindMensaje,
return items, err return items, err
} }
// GetUmindSesiones lista las sesiones de conversación recientes de un agente
// (para el panel admin), con el último mensaje como resumen.
func GetUmindSesiones(agenteID uint, limit int) ([]UmindMensaje, error) {
var items []UmindMensaje
err := app.Http.Database.DB.Raw(`
SELECT * FROM (
SELECT DISTINCT ON (session_id) *
FROM umind_mensajes
WHERE agente_id = ? AND deleted_at IS NULL
ORDER BY session_id, created_at DESC
) ultimos
ORDER BY created_at DESC
LIMIT ?
`, agenteID, limit).Scan(&items).Error
return items, err
}
// SesionResumen es una conversación vista desde el reporte: cuándo empezó, // SesionResumen es una conversación vista desde el reporte: cuándo empezó,
// cuántos mensajes tuvo y con qué la abrió el visitante. El primer mensaje es // cuántos mensajes tuvo y con qué la abrió el visitante. El primer mensaje es
// lo más útil del conjunto — muestra qué le preguntan de verdad al negocio. // lo más útil del conjunto — muestra qué le preguntan de verdad al negocio.
@@ -279,6 +334,36 @@ type SesionResumen struct {
Inicio time.Time `json:"inicio"` Inicio time.Time `json:"inicio"`
Mensajes int64 `json:"mensajes"` Mensajes int64 `json:"mensajes"`
PrimerMensaje string `json:"primer_mensaje"` PrimerMensaje string `json:"primer_mensaje"`
// Ultimo es la última actividad: es lo que ordena la bandeja del panel.
// El reporte mensual no lo usa (ordena por inicio) y ahí queda en cero.
Ultimo time.Time `json:"ultimo"`
}
// GetUmindSesionesPanel es la bandeja de conversaciones del panel: las más
// recientes primero, con lo que el visitante preguntó al abrir — no con la
// última respuesta del bot, que era lo que se mostraba antes y no decía nada
// ("¡Hola! ¿En qué te ayudo?" cincuenta veces).
func GetUmindSesionesPanel(agenteID uint, limit int) ([]SesionResumen, error) {
var items []SesionResumen
err := app.Http.Database.DB.Raw(`
SELECT
m.session_id,
MIN(m.created_at) AS inicio,
MAX(m.created_at) AS ultimo,
COUNT(*) AS mensajes,
COALESCE((
SELECT p.content FROM umind_mensajes p
WHERE p.session_id = m.session_id AND p.agente_id = m.agente_id
AND p.role = 'user' AND p.deleted_at IS NULL
ORDER BY p.created_at ASC LIMIT 1
), '') AS primer_mensaje
FROM umind_mensajes m
WHERE m.agente_id = ? AND m.deleted_at IS NULL
GROUP BY m.session_id, m.agente_id
ORDER BY ultimo DESC
LIMIT ?
`, agenteID, limit).Scan(&items).Error
return items, err
} }
// GetUmindSesionesRango agrupa los mensajes en conversaciones dentro de un // GetUmindSesionesRango agrupa los mensajes en conversaciones dentro de un
+9
View File
@@ -47,6 +47,15 @@ func GetUmindAgentesByTenant(tenantID uint) ([]UmindAgente, error) {
return items, err return items, err
} }
// GetTodosLosUmindAgentes lista todos los agentes de todos los tenants. Es solo
// para pantallas de staff que necesitan elegir uno (ej. de qué base de
// conocimiento salen los borradores de soporte).
func GetTodosLosUmindAgentes() ([]UmindAgente, error) {
var items []UmindAgente
err := app.Http.Database.DB.Order("nombre ASC").Find(&items).Error
return items, err
}
func GetUmindAgenteByID(id uint) (*UmindAgente, error) { func GetUmindAgenteByID(id uint) (*UmindAgente, error) {
var a UmindAgente var a UmindAgente
if err := app.Http.Database.DB.First(&a, id).Error; err != nil { if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
+3
View File
@@ -33,6 +33,9 @@ type UmindCanal struct {
// antes de pasarlos al agente, en vez de ignorarse. // antes de pasarlos al agente, en vez de ignorarse.
UsarWhisperAudio bool `json:"usar_whisper_audio" gorm:"column:usar_whisper_audio;default:false"` UsarWhisperAudio bool `json:"usar_whisper_audio" gorm:"column:usar_whisper_audio;default:false"`
UsarOcrImagenes bool `json:"usar_ocr_imagenes" gorm:"column:usar_ocr_imagenes;default:false"` UsarOcrImagenes bool `json:"usar_ocr_imagenes" gorm:"column:usar_ocr_imagenes;default:false"`
// Documentos adjuntos (PDF, Word, texto): se les extrae el contenido y se
// le pasa al agente como si el cliente lo hubiera escrito.
UsarArchivosDocs bool `json:"usar_archivos_docs" gorm:"column:usar_archivos_docs;default:false"`
} }
func (UmindCanal) TableName() string { return "umind_canales" } func (UmindCanal) TableName() string { return "umind_canales" }
+30 -2
View File
@@ -33,6 +33,11 @@ type UmindUso struct {
Moneda string `json:"moneda" gorm:"column:moneda;size:3"` Moneda string `json:"moneda" gorm:"column:moneda;size:3"`
// FacturadoAt null = pendiente de cobrar en el próximo ciclo. // FacturadoAt null = pendiente de cobrar en el próximo ciclo.
FacturadoAt *time.Time `json:"facturado_at" gorm:"column:facturado_at;index"` FacturadoAt *time.Time `json:"facturado_at" gorm:"column:facturado_at;index"`
// CuentaPropia marca el consumo que salió por la cuenta de IA del propio
// cliente. Se sigue midiendo —quiere ver cuánto usa su asistente— pero con
// costo cero: ya se lo factura su proveedor, y cobrárselo también sería
// cobrar dos veces por lo mismo.
CuentaPropia bool `json:"cuenta_propia" gorm:"column:cuenta_propia;default:false;index"`
} }
func (UmindUso) TableName() string { return "umind_uso" } func (UmindUso) TableName() string { return "umind_uso" }
@@ -55,12 +60,22 @@ func RegistrarUsoUmind(agenteID uint, tipo string, cantidad float64, unidad stri
if plan != nil { if plan != nil {
moneda = plan.Moneda moneda = plan.Moneda
} }
costo := costoDeUso(plan, tipo, cantidad)
// Si el agente corre sobre la cuenta de IA del propio cliente, el consumo
// se registra igual pero no se le cobra: su proveedor ya se lo factura.
// Solo aplica a los tokens de IA — el OCR y la transcripción son nuestros
// servicios, los use quien los use.
propia := tipo == UsoTipoIA && agenteUsaCuentaPropia(agente)
costo := 0.0
if !propia {
costo = costoDeUso(plan, tipo, cantidad)
}
uso := &UmindUso{ uso := &UmindUso{
TenantID: agente.TenantID, AgenteID: agenteID, TenantID: agente.TenantID, AgenteID: agenteID,
Tipo: tipo, Cantidad: cantidad, Unidad: unidad, Tipo: tipo, Cantidad: cantidad, Unidad: unidad,
Costo: costo, Moneda: moneda, Costo: costo, Moneda: moneda, CuentaPropia: propia,
} }
if err := app.Http.Database.DB.Create(uso).Error; err != nil { if err := app.Http.Database.DB.Create(uso).Error; err != nil {
log.Printf("[UMIND_USO] no se pudo registrar consumo del agente %d (%s %.2f %s): %v", agenteID, tipo, cantidad, unidad, err) log.Printf("[UMIND_USO] no se pudo registrar consumo del agente %d (%s %.2f %s): %v", agenteID, tipo, cantidad, unidad, err)
@@ -204,3 +219,16 @@ func MarcarUsoFacturadoPorCliente(clienteID uint) {
log.Printf("[UMIND_USO] no se pudo marcar como facturado el consumo del cliente %d: %v", clienteID, err) log.Printf("[UMIND_USO] no se pudo marcar como facturado el consumo del cliente %d: %v", clienteID, err)
} }
} }
// agenteUsaCuentaPropia dice si el agente apunta a una config de IA cargada por
// el cliente (con tenant), en vez de a una nuestra.
func agenteUsaCuentaPropia(agente *UmindAgente) bool {
if agente == nil || agente.AiConfigID == nil {
return false
}
var cfg AiConfig
if err := GetAiConfigByID(*agente.AiConfigID, &cfg); err != nil {
return false
}
return cfg.TenantID != nil
}
+193
View File
@@ -0,0 +1,193 @@
package services
import (
"archive/zip"
"bytes"
"compress/zlib"
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"unicode"
)
// ExtraerTextoDeArchivo saca el texto de un archivo cualquiera para dárselo al
// agente. Es el equivalente de Whisper para audio y OCR para imágenes, pero
// para documentos.
//
// agenteID identifica a quién cobrarle si hace falta pasar por OCR; 0 = no medir.
func ExtraerTextoDeArchivo(agenteID uint, nombreArchivo string, datos []byte) (string, error) {
ext := strings.ToLower(filepath.Ext(nombreArchivo))
switch ext {
case ".txt", ".md", ".csv", ".json", ".xml", ".log", ".html", ".htm":
return string(datos), nil
case ".docx":
return textoDeDocx(datos)
case ".pdf":
return textoDePDF(agenteID, datos)
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff":
return ExtraerTextoOCR(agenteID, datos, mimeDeImagen(ext))
default:
return "", fmt.Errorf("no sé leer archivos %s; probá con PDF, Word (.docx), texto o una imagen", ext)
}
}
func mimeDeImagen(ext string) string {
switch ext {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".tif", ".tiff":
return "image/tiff"
default:
return "image/" + strings.TrimPrefix(ext, ".")
}
}
var etiquetaXML = regexp.MustCompile(`<[^>]+>`)
// textoDeDocx lee word/document.xml del .docx y lo aplana a texto. No pretende
// conservar el formato: el agente solo necesita el contenido y el orden.
func textoDeDocx(datos []byte) (string, error) {
zr, err := zip.NewReader(bytes.NewReader(datos), int64(len(datos)))
if err != nil {
return "", fmt.Errorf("el .docx no se pudo abrir: %w", err)
}
for _, f := range zr.File {
if f.Name != "word/document.xml" {
continue
}
rc, err := f.Open()
if err != nil {
return "", err
}
defer rc.Close()
xmlBytes, err := io.ReadAll(io.LimitReader(rc, 8<<20))
if err != nil {
return "", err
}
s := string(xmlBytes)
// Un párrafo, un salto de línea explícito y un fin de fila valen como
// salto; las celdas se separan con tab. El resto de etiquetas se tira.
s = strings.NewReplacer("</w:p>", "\n", "<w:br/>", "\n", "</w:tr>", "\n", "</w:tc>", "\t").Replace(s)
s = etiquetaXML.ReplaceAllString(s, "")
s = strings.NewReplacer("&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`, "&apos;", "'").Replace(s)
return strings.TrimSpace(s), nil
}
return "", fmt.Errorf("el archivo no parece un .docx (no tiene word/document.xml)")
}
// textoDePDF saca el texto de un PDF digital (facturas, cotizaciones, cualquier
// cosa exportada por un programa) leyendo los operadores de texto de sus
// streams. Si el PDF es un escaneo no hay texto que leer, y ahí cae al OCR.
//
// ponytail: parser mínimo — entiende streams FlateDecode y los operadores Tj/TJ,
// que es lo que usan los PDFs generados por software. No maneja fuentes con
// codificaciones raras ni CID; para esos casos el fallback a OCR es la salida.
func textoDePDF(agenteID uint, datos []byte) (string, error) {
texto := strings.TrimSpace(textoDeStreamsPDF(datos))
// Un PDF escaneado devuelve nada o cuatro letras sueltas de un encabezado.
if len([]rune(texto)) >= 40 {
return texto, nil
}
ocrTexto, err := ExtraerTextoOCR(agenteID, datos, "application/pdf")
if err != nil {
if texto != "" {
return texto, nil
}
return "", fmt.Errorf("el PDF no tiene texto legible y el OCR no pudo procesarlo: %w", err)
}
return ocrTexto, nil
}
var streamRe = regexp.MustCompile(`(?s)stream\r?\n(.*?)endstream`)
func textoDeStreamsPDF(datos []byte) string {
var out strings.Builder
for _, m := range streamRe.FindAllSubmatch(datos, -1) {
crudo := m[1]
contenido := crudo
if zr, err := zlib.NewReader(bytes.NewReader(crudo)); err == nil {
if inflado, err := io.ReadAll(io.LimitReader(zr, 16<<20)); err == nil {
contenido = inflado
}
zr.Close()
}
if !bytes.Contains(contenido, []byte("Tj")) && !bytes.Contains(contenido, []byte("TJ")) {
continue
}
out.WriteString(textoDeContenidoPDF(contenido))
}
return out.String()
}
// textoDeContenidoPDF junta las cadenas entre paréntesis de un content stream,
// que es donde vive el texto visible, y respeta los saltos de línea (T*, TD, Td).
func textoDeContenidoPDF(contenido []byte) string {
var out strings.Builder
for i := 0; i < len(contenido); i++ {
switch contenido[i] {
case '(':
var s strings.Builder
for i++; i < len(contenido); i++ {
c := contenido[i]
if c == '\\' && i+1 < len(contenido) {
i++
switch contenido[i] {
case 'n':
s.WriteByte('\n')
case 't':
s.WriteByte('\t')
case 'r':
default:
s.WriteByte(contenido[i])
}
continue
}
if c == ')' {
break
}
s.WriteByte(c)
}
out.WriteString(s.String())
case 'T':
// T* / Td / TD mueven el cursor a otra línea.
if i+1 < len(contenido) {
switch contenido[i+1] {
case '*', 'd', 'D':
out.WriteByte('\n')
}
}
}
}
return limpiarNoImprimibles(out.String())
}
func limpiarNoImprimibles(s string) string {
return strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || unicode.IsPrint(r) {
return r
}
return -1
}, s)
}
// TextoDeArchivoParaAgente arma el mensaje que ve el agente cuando alguien le
// manda un documento: el contenido solo, sin contexto, hace que el modelo
// conteste como si el cliente hubiera escrito una factura.
func TextoDeArchivoParaAgente(nombreArchivo, caption, contenido string) string {
if len(contenido) > 30000 {
contenido = contenido[:30000] + "\n…(archivo recortado)"
}
var b strings.Builder
b.WriteString("El cliente adjuntó un archivo")
if nombreArchivo != "" {
b.WriteString(" llamado \"" + nombreArchivo + "\"")
}
b.WriteString(".")
if strings.TrimSpace(caption) != "" {
b.WriteString(" Escribió junto al archivo: " + strings.TrimSpace(caption))
}
b.WriteString("\n\nContenido del archivo:\n---\n" + strings.TrimSpace(contenido) + "\n---")
return b.String()
}
+68
View File
@@ -0,0 +1,68 @@
package services
import (
"bytes"
"compress/zlib"
"strconv"
"strings"
"testing"
)
// pdfDePrueba arma un PDF mínimo con el content stream comprimido, igual que
// los que genera cualquier programa que exporta a PDF.
func pdfDePrueba(t *testing.T, contenido string) []byte {
t.Helper()
var comp bytes.Buffer
zw := zlib.NewWriter(&comp)
if _, err := zw.Write([]byte(contenido)); err != nil {
t.Fatal(err)
}
zw.Close()
var pdf bytes.Buffer
pdf.WriteString("%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n")
pdf.WriteString("2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n")
pdf.WriteString("3 0 obj<</Type/Page/Parent 2 0 R/Contents 4 0 R>>endobj\n")
pdf.WriteString("4 0 obj<</Length " + strconv.Itoa(comp.Len()) + "/Filter/FlateDecode>>stream\n")
pdf.Write(comp.Bytes())
pdf.WriteString("\nendstream endobj\ntrailer<</Root 1 0 R>>\n%%EOF")
return pdf.Bytes()
}
func TestExtraerTextoDeArchivoPDFDigital(t *testing.T) {
contenido := `BT /F1 12 Tf 72 720 Td (FACTURA DE VENTA No. 1042) Tj T* ` +
`(Cliente: Acme SAS NIT 900.123.456-7) Tj T* (Total: \$1.500.000 COP) Tj ET`
got, err := ExtraerTextoDeArchivo(0, "factura.pdf", pdfDePrueba(t, contenido))
if err != nil {
t.Fatalf("ExtraerTextoDeArchivo: %v", err)
}
for _, quiero := range []string{"FACTURA DE VENTA No. 1042", "Acme SAS", "NIT 900.123.456-7", "$1.500.000 COP"} {
if !strings.Contains(got, quiero) {
t.Errorf("falta %q en el texto extraído:\n%s", quiero, got)
}
}
// T* separa renglones: sin eso la factura llega al agente como un chorizo.
if lineas := strings.Count(strings.TrimSpace(got), "\n"); lineas < 2 {
t.Errorf("esperaba al menos 3 renglones, hay %d:\n%s", lineas+1, got)
}
}
func TestExtraerTextoDeArchivoTexto(t *testing.T) {
got, err := ExtraerTextoDeArchivo(0, "notas.txt", []byte("hola\nmundo"))
if err != nil || got != "hola\nmundo" {
t.Errorf("got %q, err %v", got, err)
}
if _, err := ExtraerTextoDeArchivo(0, "cosa.exe", []byte("x")); err == nil {
t.Error("una extensión desconocida debería devolver error")
}
}
func TestTextoDeArchivoParaAgente(t *testing.T) {
got := TextoDeArchivoParaAgente("factura.pdf", "me cobraron de más", "Total: 1000")
for _, quiero := range []string{"factura.pdf", "me cobraron de más", "Total: 1000"} {
if !strings.Contains(got, quiero) {
t.Errorf("falta %q en:\n%s", quiero, got)
}
}
}
+15 -1
View File
@@ -78,8 +78,22 @@ func IniciarCron() {
return return
} }
// Conocimiento de los agentes que se actualiza solo — 4 AM, cuando nadie
// está mirando: recrawlear varios sitios no es gratis.
if _, err := cronScheduler.AddFunc("0 4 * * *", RefrescarConocimientoUmind); err != nil {
log.Printf("[CRON] Error registrando tarea refresco_conocimiento: %v", err)
return
}
// Buzón de soporte por IMAP — cada 2 minutos. No hace nada si no está
// configurado, así que registrarlo siempre no cuesta.
if _, err := cronScheduler.AddFunc("*/2 * * * *", RevisarBuzonSoporte); err != nil {
log.Printf("[CRON] Error registrando tarea soporte_imap: %v", err)
return
}
cronScheduler.Start() cronScheduler.Start()
log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min") log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min, buzón de soporte cada 2min")
} }
// DetenerCron para graceful shutdown // DetenerCron para graceful shutdown
+69
View File
@@ -0,0 +1,69 @@
package services
import (
"fmt"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// CompletarTextoIA hace una llamada simple (sin tools, sin streaming) al
// proveedor configurado para el servicio dado y devuelve el texto.
//
// Reusa callAI, que es el mismo despachador del bot de Telegram: sabe hablar
// Anthropic y OpenAI-compatible, y completa la URL base cuando la config la
// tiene vacía. Escribir una segunda implementación acá fue un error: no
// soportaba Anthropic y armaba una URL relativa cuando faltaba la base, así
// que fallaba con la config que ya estaba en producción.
func CompletarTextoIA(servicio, sistema, usuario string) (string, error) {
config, err := models.GetAiConfigForService(servicio)
if err != nil {
return "", fmt.Errorf("no hay configuración de IA activa; configurá una en /app/ai-config")
}
if strings.TrimSpace(config.ModelName) == "" {
return "", fmt.Errorf("la configuración de IA %q no tiene modelo definido (/app/ai-config)", config.Nombre)
}
msg, _, err := callAI(config, []agentMessage{
{Role: "system", Content: sistema},
{Role: "user", Content: usuario},
}, nil)
if err != nil {
return "", fmt.Errorf("%s (%s): %w", config.Nombre, config.Provider, err)
}
texto, _ := msg.Content.(string)
if strings.TrimSpace(texto) == "" {
return "", fmt.Errorf("%s no devolvió texto", config.Nombre)
}
return texto, nil
}
// ProbarAiConfig manda una consulta mínima a una configuración concreta y
// devuelve lo que contestó.
//
// A diferencia de CompletarTextoIA, no resuelve la config por módulo: recibe la
// que hay que probar. Es lo que necesita alguien que acaba de cargar su cuenta
// y quiere saber si la clave sirve, antes de que lo descubra un cliente real.
func ProbarAiConfig(cfg *models.AiConfig) (string, error) {
if cfg == nil {
return "", fmt.Errorf("no hay configuración que probar")
}
if strings.TrimSpace(cfg.ModelName) == "" {
return "", fmt.Errorf("falta el modelo en esta configuración")
}
msg, _, err := callAI(cfg, []agentMessage{
{Role: "system", Content: "Respondé con una sola palabra."},
{Role: "user", Content: "Decí \"listo\" si me estás leyendo."},
}, nil)
if err != nil {
return "", fmt.Errorf("%s rechazó la consulta: %w", cfg.Provider, err)
}
texto, _ := msg.Content.(string)
if strings.TrimSpace(texto) == "" {
return "", fmt.Errorf("%s respondió, pero sin texto: revisá el modelo", cfg.Provider)
}
return strings.TrimSpace(texto), nil
}
+16 -1
View File
@@ -10,10 +10,13 @@ import (
// ─── DispatchTicketNuevo ────────────────────────────────────────────────────── // ─── DispatchTicketNuevo ──────────────────────────────────────────────────────
// Notifica al admin cuando un portal user crea un ticket. // Notifica al admin cuando un portal user crea un ticket.
// portalUser puede ser nil: los tickets que entran por correo no tienen usuario
// de portal detrás, y no por eso hay que dejar de avisar.
func DispatchTicketNuevo(ticket *models.ProyectoTicket, portalUser *models.PortalUser, proyectoNombre string) { func DispatchTicketNuevo(ticket *models.ProyectoTicket, portalUser *models.PortalUser, proyectoNombre string) {
if ticket == nil || portalUser == nil { if ticket == nil {
return return
} }
_ = portalUser
cfg := models.GetNotifConfig("ticket_nuevo", "admin") cfg := models.GetNotifConfig("ticket_nuevo", "admin")
if cfg == nil { if cfg == nil {
return return
@@ -409,10 +412,22 @@ func sendTelegramAdmin(mensaje string) {
log.Printf("[Notif] Error obteniendo configs telegram: %v", err) log.Printf("[Notif] Error obteniendo configs telegram: %v", err)
return return
} }
// Un mismo chat puede estar cargado en más de una configuración (dos bots
// apuntando al mismo grupo, o la misma config duplicada). Sin esto, cada
// aviso llega repetido tantas veces como filas haya.
yaEnviado := map[string]bool{}
for _, cfg := range configs { for _, cfg := range configs {
if !cfg.Activo { if !cfg.Activo {
continue continue
} }
destino := cfg.BotToken + "→" + cfg.ChatID
if yaEnviado[destino] {
log.Printf("[Notif] Chat %s repetido en la config %d (%s), no se manda de nuevo", cfg.ChatID, cfg.ID, cfg.Nombre)
continue
}
yaEnviado[destino] = true
ts := &TelegramService{BotToken: cfg.BotToken} ts := &TelegramService{BotToken: cfg.BotToken}
sendErr := ts.SendMessage(cfg.ChatID, mensaje) sendErr := ts.SendMessage(cfg.ChatID, mensaje)
logEntry := &models.TelegramLog{ logEntry := &models.TelegramLog{
+94
View File
@@ -0,0 +1,94 @@
package services
import (
"fmt"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// variablesPorTipo documenta, para la IA, qué campos recibe cada plantilla al
// renderizarse. Sale de DatosBaseDocumento + lo que arma cada generador
// (ver CrearCotizacion, contrato_documento_service, cuenta_cobro_documento_service).
var variablesPorTipo = map[string]string{
"cotizacion": `{{.Cliente.Nombre}}, {{.Cliente.Documento}}, {{.Cliente.Email}}, {{.Cliente.Telefono}},
{{.Alcance}}, {{.TipoProyecto}}, {{.Total}},
{{range .Items}}{{.Descripcion}} {{.Cantidad}} {{.Unidad}} {{.ValorUnitario}}{{end}}`,
"contrato": `{{.Cliente.Nombre}}, {{.Cliente.Documento}}, {{.Alcance}}, {{.Total}}, {{.Servicio}}, {{.Periodicidad}}`,
"acta": `{{.Cliente.Nombre}}, {{.Proyecto}}, {{.Alcance}}, {{.Entregables}}`,
"cuenta_cobro": `{{.Cliente.Nombre}}, {{.Cliente.Documento}}, {{.Concepto}}, {{.Total}}, {{.Numero}}`,
}
const variablesComunes = `{{.Fecha}}, {{.EmpresaNombre}}, {{.EmpresaWeb}}`
// ExtraerTextoDePlantilla lee el archivo que subió el admin como referencia.
// agenteID 0: es una acción del staff, no se le cobra a ningún cliente.
func ExtraerTextoDePlantilla(nombreArchivo string, datos []byte) (string, error) {
return ExtraerTextoDeArchivo(0, nombreArchivo, datos)
}
// ConvertirEnPlantilla le pide a la IA que rearme el documento como plantilla
// HTML con las variables Go que usa el generador. Lo que devuelve va al editor
// para que el admin lo revise antes de guardar: no se guarda solo.
func ConvertirEnPlantilla(tipo, textoDocumento string) (string, error) {
if strings.TrimSpace(textoDocumento) == "" {
return "", fmt.Errorf("no se pudo leer texto del archivo")
}
if len(textoDocumento) > 20000 {
textoDocumento = textoDocumento[:20000]
}
vars := variablesPorTipo[tipo]
if vars == "" {
vars = variablesComunes
}
sistema := `Sos un asistente que convierte documentos en plantillas HTML para Go text/template.
Reglas:
- Devolvé SOLO el HTML de la plantilla, sin explicaciones y sin bloques de código markdown.
- Reemplazá los datos concretos del documento (nombres, NITs, fechas, montos, ítems) por las variables de la lista. Lo que sea texto fijo del formato se deja tal cual.
- Si el documento tiene una tabla de ítems, usá {{range .Items}}{{end}} para las filas.
- Usá estilos inline (style="…"), sin CSS externo ni <script>: el HTML se convierte a PDF.
- No inventes variables que no estén en la lista.`
usuario := fmt.Sprintf(`Tipo de documento: %s
Variables disponibles:
%s
%s
Documento de referencia:
---
%s
---`, tipo, variablesComunes, vars, textoDocumento)
// Si el admin asignó una config al módulo "plantillas" se usa esa; si no,
// sigue saliendo por "ia" como hasta ahora. Convertir un documento entero
// pide un modelo más capaz que el resto de las tareas, y esto deja
// elegirlo sin tocar lo que ya usa la vCard.
modulo := "ia"
if models.HayAiConfigParaModulo("plantillas") {
modulo = "plantillas"
}
salida, err := CompletarTextoIA(modulo, sistema, usuario)
if err != nil {
return "", err
}
return limpiarCercaDeCodigo(salida), nil
}
// limpiarCercaDeCodigo saca el ```html … ``` que los modelos agregan aunque se
// les pida que no lo hagan.
func limpiarCercaDeCodigo(s string) string {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "```") {
return s
}
if i := strings.Index(s, "\n"); i >= 0 {
s = s[i+1:]
}
if i := strings.LastIndex(s, "```"); i >= 0 {
s = s[:i]
}
return strings.TrimSpace(s)
}
+67
View File
@@ -0,0 +1,67 @@
package services
import (
"archive/zip"
"bytes"
"strings"
"testing"
)
func docxDePrueba(t *testing.T, documentXML string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("word/document.xml")
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(documentXML)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestExtraerTextoDePlantillaDocx(t *testing.T) {
xml := `<?xml version="1.0"?><w:document><w:body>` +
`<w:p><w:r><w:t>COTIZACI&amp;Oacute;N</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>Cliente: </w:t></w:r><w:r><w:t>Acme SAS</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>Total: $1.500.000</w:t></w:r></w:p>` +
`</w:body></w:document>`
got, err := ExtraerTextoDePlantilla("cotizacion.docx", docxDePrueba(t, xml))
if err != nil {
t.Fatalf("ExtraerTextoDePlantilla: %v", err)
}
// Los runs de un mismo párrafo quedan pegados; los párrafos, en líneas.
if !strings.Contains(got, "Cliente: Acme SAS") {
t.Errorf("falta la línea del cliente en:\n%s", got)
}
if !strings.Contains(got, "Total: $1.500.000") {
t.Errorf("falta el total en:\n%s", got)
}
if strings.Contains(got, "<w:") {
t.Errorf("quedaron etiquetas XML en:\n%s", got)
}
}
func TestExtraerTextoDePlantillaFormatoNoSoportado(t *testing.T) {
if _, err := ExtraerTextoDePlantilla("plantilla.xyz", []byte("x")); err == nil {
t.Error("una extensión desconocida debería devolver error")
}
}
func TestLimpiarCercaDeCodigo(t *testing.T) {
casos := map[string]string{
"```html\n<p>hola</p>\n```": "<p>hola</p>",
"```\n<p>hola</p>\n```": "<p>hola</p>",
"<p>hola</p>": "<p>hola</p>",
}
for in, want := range casos {
if got := limpiarCercaDeCodigo(in); got != want {
t.Errorf("limpiarCercaDeCodigo(%q) = %q, want %q", in, got, want)
}
}
}
+67
View File
@@ -0,0 +1,67 @@
package services
import (
"bytes"
"fmt"
"text/template"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// RenderizarPlantillaEjemplo ejecuta la plantilla con datos de muestra y
// devuelve el HTML resultante.
//
// Se ejecuta de verdad, no se hace un reemplazo de texto: es la única forma de
// que {{range .Items}} y los campos anidados se vean como van a salir, y de que
// un error de sintaxis aparezca mientras se edita y no al generar el documento.
func RenderizarPlantillaEjemplo(tipo, contenidoHTML string) (string, error) {
tmpl, err := template.New("preview").Parse(contenidoHTML)
if err != nil {
return "", fmt.Errorf("sintaxis inválida: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, DatosDeEjemploPlantilla(tipo)); err != nil {
return "", fmt.Errorf("no se pudo renderizar: %w", err)
}
return buf.String(), nil
}
// DatosDeEjemploPlantilla arma los mismos campos que le pasa cada generador
// real (ver CrearCotizacion, contrato_documento_service, cuenta_cobro), con
// valores inventados. Si esto se desincroniza de los generadores, la vista
// previa miente — por eso sale de DatosBaseDocumento, igual que en producción.
func DatosDeEjemploPlantilla(tipo string) map[string]interface{} {
cliente := &models.Cliente{
Nombre: "Acme S.A.S.",
Empresa: "Acme S.A.S.",
Documento: "900.123.456-7",
Email: "contacto@acme.com",
Telefono: "+57 300 123 4567",
}
extra := map[string]interface{}{
"Cliente": cliente,
"Alcance": "Desarrollo del sitio web institucional, con panel de administración y tres integraciones.",
"TipoProyecto": "Sitio web",
"Total": 4500000.0,
"Items": []ItemCotizacion{
{Descripcion: "Diseño de interfaz", Cantidad: 1, ValorUnitario: 1500000, Unidad: "servicio"},
{Descripcion: "Desarrollo frontend", Cantidad: 40, ValorUnitario: 50000, Unidad: "hora"},
{Descripcion: "Integración con pasarela de pagos", Cantidad: 1, ValorUnitario: 1000000, Unidad: "servicio"},
},
}
switch tipo {
case "contrato":
extra["Servicio"] = "Mantenimiento mensual del sitio"
extra["Periodicidad"] = "mensual"
case "acta":
extra["Proyecto"] = "Sitio web Acme"
extra["Entregables"] = "Sitio publicado, manual de uso y capacitación."
case "cuenta_cobro":
extra["Concepto"] = "Mantenimiento mensual — marzo"
extra["Numero"] = "CC-0042"
}
return DatosBaseDocumento(extra)
}
+68
View File
@@ -0,0 +1,68 @@
package services
import (
"strings"
"testing"
)
// La vista previa ejecuta la plantilla de verdad: es lo que hace que un
// {{range}} se vea como va a salir y que un error de sintaxis aparezca mientras
// se edita, y no recién al generar el documento.
func TestRenderizarPlantillaEjemplo(t *testing.T) {
html := `<h1>{{.Cliente.Nombre}}{{.Cliente.Documento}}</h1>
<p>{{.Alcance}}</p>
<table>{{range .Items}}<tr><td>{{.Descripcion}}</td><td>{{.Cantidad}}</td></tr>{{end}}</table>
<p>Total: {{.Total}}{{.Fecha}}{{.EmpresaNombre}}</p>`
out, err := RenderizarPlantillaEjemplo("cotizacion", html)
if err != nil {
t.Fatalf("RenderizarPlantillaEjemplo: %v", err)
}
for _, quiero := range []string{"Acme S.A.S.", "900.123.456-7", "Diseño de interfaz", "Integración con pasarela", "U-SITE"} {
if !strings.Contains(out, quiero) {
t.Errorf("falta %q en:\n%s", quiero, out)
}
}
if strings.Contains(out, "{{") {
t.Errorf("quedaron variables sin resolver:\n%s", out)
}
}
func TestRenderizarPlantillaEjemploAvisaDeErrores(t *testing.T) {
if _, err := RenderizarPlantillaEjemplo("cotizacion", "{{range .Items}}sin fin"); err == nil {
t.Error("un {{range}} sin {{end}} debería dar error")
}
// Una variable que no existe no rompe: se renderiza como "<no value>", igual
// que en la generación real. Eso se ve en la vista previa, que es el punto —
// hacerla más estricta que producción rechazaría plantillas que sí andan.
out, err := RenderizarPlantillaEjemplo("cotizacion", "Hola {{.NoExiste}}")
if err != nil {
t.Fatalf("no debería fallar: %v", err)
}
if !strings.Contains(out, "no value") {
t.Errorf("una variable inexistente tendría que notarse en la vista previa: %q", out)
}
}
// Cada tipo agrega sus propios campos; si esto se desincroniza de los
// generadores, la vista previa muestra una cosa y el PDF sale con otra.
func TestDatosDeEjemploPorTipo(t *testing.T) {
casos := map[string][]string{
"contrato": {"Servicio", "Periodicidad"},
"acta": {"Proyecto", "Entregables"},
"cuenta_cobro": {"Concepto", "Numero"},
}
for tipo, campos := range casos {
datos := DatosDeEjemploPlantilla(tipo)
for _, campo := range campos {
if _, ok := datos[campo]; !ok {
t.Errorf("faltan datos de ejemplo de %q para el tipo %q", campo, tipo)
}
}
for _, comun := range []string{"Cliente", "Fecha", "EmpresaNombre", "Total"} {
if _, ok := datos[comun]; !ok {
t.Errorf("falta el campo común %q en el tipo %q", comun, tipo)
}
}
}
}
+77
View File
@@ -0,0 +1,77 @@
package services
import (
"fmt"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// RedactarBorradorTicket propone una respuesta para un ticket, apoyándose en la
// base de conocimiento del agente de uMind que se haya elegido en la
// configuración de soporte.
//
// Devuelve un borrador, no una respuesta: no se manda a nadie. Contestarle solo
// a un cliente con lo que dijo un modelo es la forma más rápida de perderlo;
// acá lo lee una persona, lo corrige y aprieta enviar.
func RedactarBorradorTicket(ticket *models.ProyectoTicket) (string, error) {
if ticket == nil {
return "", fmt.Errorf("ticket no encontrado")
}
cfg, _ := models.GetSoporteWebhookActivo()
var conversacion strings.Builder
fmt.Fprintf(&conversacion, "Asunto: %s\n\n%s: %s\n", ticket.Titulo, ticket.AutorNombre, ticket.Descripcion)
for _, m := range ticket.Mensajes {
quien := ticket.AutorNombre
if m.EsAdmin {
quien = "Soporte"
}
fmt.Fprintf(&conversacion, "\n%s: %s\n", quien, m.Contenido)
}
sistema := `Redactás borradores de respuesta para un equipo de soporte. Los lee una
persona antes de enviarlos.
- Escribí en el mismo idioma que usó el cliente.
- Respondé lo que preguntó, directo, sin relleno ni frases de manual.
- Usá SOLO lo que esté en la documentación de referencia y en la conversación.
Si falta información para resolverlo, decilo y proponé qué preguntarle.
- Nunca inventes precios, plazos, funciones ni pasos que no estén escritos.
- Sin asunto, sin firma: solo el cuerpo del mensaje.`
if cfg != nil && strings.TrimSpace(cfg.ContextoNegocio) != "" {
sistema += "\n\nContexto del negocio:\n" + strings.TrimSpace(cfg.ContextoNegocio)
}
usuario := conversacion.String()
if doc := documentacionRelevante(cfg, ticket); doc != "" {
usuario = "Documentación de referencia:\n---\n" + doc + "\n---\n\nConversación:\n" + usuario
}
borrador, err := CompletarTextoIA("ia", sistema, usuario)
if err != nil {
return "", err
}
return strings.TrimSpace(borrador), nil
}
// documentacionRelevante busca en la base de conocimiento del agente elegido.
// Sin agente configurado devuelve vacío y el borrador se arma solo con la
// conversación — peor, pero mejor que un error.
func documentacionRelevante(cfg *models.SoporteWebhookConfig, ticket *models.ProyectoTicket) string {
if cfg == nil || cfg.AgenteBorradorID == nil || *cfg.AgenteBorradorID == 0 {
return ""
}
consulta := ticket.Titulo + "\n" + ticket.Descripcion
chunks, err := BuscarConocimiento(*cfg.AgenteBorradorID, consulta, 4)
if err != nil || len(chunks) == 0 {
return ""
}
var b strings.Builder
for _, ch := range chunks {
b.WriteString(strings.TrimSpace(ch.Contenido))
b.WriteString("\n\n")
}
return strings.TrimSpace(b.String())
}
@@ -0,0 +1,96 @@
package services
import (
"encoding/json"
"fmt"
"log"
"strings"
)
// ClasificacionCorreo es el veredicto sobre un correo entrante.
type ClasificacionCorreo struct {
EsSoporte bool `json:"es_soporte"`
Categoria string `json:"categoria"` // error | facturacion | acceso | consulta | comercial | newsletter | spam | otro
Prioridad string `json:"prioridad"` // baja | media | alta | urgente
Motivo string `json:"motivo"`
}
// ClasificarCorreoSoporte decide si un correo entrante es una solicitud de
// soporte o ruido (newsletters, notificaciones automáticas, facturas de
// proveedores, spam).
//
// Falla abierto a propósito: si la IA no está configurada, se cae o devuelve
// cualquier cosa, el correo se trata como soporte. Perder el mensaje de un
// cliente porque el modelo estaba caído es mucho peor que abrir un ticket de
// más.
func ClasificarCorreoSoporte(correo CorreoSoporte, contextoNegocio string) ClasificacionCorreo {
// Los correos masivos se reconocen por sus propios encabezados; no hace
// falta gastar una llamada de IA para saber que un newsletter no es soporte.
if correo.Automatico {
return ClasificacionCorreo{
EsSoporte: false,
Categoria: "newsletter",
Prioridad: "baja",
Motivo: "el correo viene marcado como masivo o automático en sus encabezados",
}
}
cuerpo := strings.TrimSpace(correo.Texto)
if len([]rune(cuerpo)) > 3000 {
cuerpo = string([]rune(cuerpo)[:3000])
}
sistema := `Clasificás correos que llegan a la casilla de soporte de una empresa.
Respondé SOLO con este JSON, sin explicaciones ni bloques de código:
{"es_soporte": true|false, "categoria": "...", "prioridad": "...", "motivo": "una frase corta"}
es_soporte = true cuando una persona pide ayuda, reporta un problema, hace una
consulta sobre un servicio contratado o responde una conversación de soporte.
es_soporte = false para newsletters, promociones, notificaciones automáticas de
plataformas, facturas de proveedores y spam.
Ante la duda, es_soporte = true: es peor ignorar a un cliente que abrir un ticket de más.
categoria: error | facturacion | acceso | consulta | comercial | newsletter | spam | otro
prioridad: urgente si algo está caído, si hay plata o datos en riesgo, o si el
cliente dice que está bloqueado; alta si no puede trabajar pero tiene cómo
seguir; media para el resto; baja para consultas sin apuro y para lo que no es
soporte. La urgencia la da el problema, no el tono del mensaje.`
if c := strings.TrimSpace(contextoNegocio); c != "" {
sistema += "\n\nContexto del negocio:\n" + c
}
usuario := fmt.Sprintf("De: %s\nAsunto: %s\n\n%s", correo.From, correo.Subject, cuerpo)
salida, err := CompletarTextoIA("ia", sistema, usuario)
if err != nil {
log.Printf("[Soporte] No se pudo clasificar el correo, se trata como soporte: %v", err)
return ClasificacionCorreo{EsSoporte: true, Categoria: "otro", Prioridad: "media", Motivo: "la clasificación falló"}
}
var out ClasificacionCorreo
if err := json.Unmarshal([]byte(soloJSON(salida)), &out); err != nil {
log.Printf("[Soporte] Clasificación ilegible (%q), se trata como soporte", recortar(salida, 120))
return ClasificacionCorreo{EsSoporte: true, Categoria: "otro", Prioridad: "media", Motivo: "respuesta ilegible del modelo"}
}
if out.Categoria == "" {
out.Categoria = "otro"
}
switch out.Prioridad {
case "baja", "media", "alta", "urgente":
default:
out.Prioridad = "media"
}
return out
}
// soloJSON recorta lo que rodea al objeto JSON: los modelos agregan cercas de
// código o una frase antes aunque se les pida que no.
func soloJSON(s string) string {
i := strings.Index(s, "{")
j := strings.LastIndex(s, "}")
if i < 0 || j <= i {
return s
}
return s[i : j+1]
}
+71
View File
@@ -0,0 +1,71 @@
package services
import (
"net/mail"
"strings"
"testing"
)
// Los correos masivos se descartan por sus propios encabezados: este test pasa
// sin base de datos ni IA, que es justamente la prueba de que no las usa.
func TestClasificarCorreoAutomaticoNoGastaIA(t *testing.T) {
cl := ClasificarCorreoSoporte(CorreoSoporte{
From: "news@marketing.com", Subject: "20% off", Texto: "Oferta", Automatico: true,
}, "")
if cl.EsSoporte {
t.Error("un correo masivo no debería contar como soporte")
}
if cl.Categoria != "newsletter" {
t.Errorf("Categoria = %q", cl.Categoria)
}
}
func TestEsCorreoAutomatico(t *testing.T) {
casos := []struct {
nombre string
cabeceras string
want bool
}{
{"newsletter", "List-Unsubscribe: <mailto:baja@x.com>\r\n", true},
{"lista", "List-Id: <avisos.x.com>\r\n", true},
{"bulk", "Precedence: bulk\r\n", true},
{"auto-generado", "Auto-Submitted: auto-generated\r\n", true},
{"auto-submitted no", "Auto-Submitted: no\r\n", false},
{"persona", "", false},
}
for _, c := range casos {
msg, err := mail.ReadMessage(strings.NewReader(
"From: a@b.com\r\nSubject: x\r\n" + c.cabeceras + "\r\ncuerpo\r\n"))
if err != nil {
t.Fatalf("%s: %v", c.nombre, err)
}
if got := esCorreoAutomatico(msg.Header); got != c.want {
t.Errorf("%s: esCorreoAutomatico = %v, want %v", c.nombre, got, c.want)
}
}
}
func TestSoloJSON(t *testing.T) {
casos := map[string]string{
"```json\n{\"es_soporte\":true}\n```": `{"es_soporte":true}`,
"Claro:\n{\"es_soporte\":false}\nEspero...": `{"es_soporte":false}`,
`{"es_soporte":true}`: `{"es_soporte":true}`,
}
for in, want := range casos {
if got := soloJSON(in); got != want {
t.Errorf("soloJSON(%q) = %q, want %q", in, got, want)
}
}
}
func TestClasificarNormalizaPrioridad(t *testing.T) {
// El correo automático corta antes de llamar a la IA, así que es el único
// camino que se puede probar sin base de datos — y ahí la prioridad tiene
// que salir puesta igual.
cl := ClasificarCorreoSoporte(CorreoSoporte{
From: "news@x.com", Subject: "promo", Automatico: true,
}, "")
if cl.Prioridad != "baja" {
t.Errorf("Prioridad = %q, want baja", cl.Prioridad)
}
}
+166
View File
@@ -0,0 +1,166 @@
package services
import (
"io"
"net"
"strings"
"testing"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-imap/v2/imapserver"
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
)
// servidorDePrueba levanta un IMAP en memoria con un correo sin leer en INBOX.
func servidorDePrueba(t *testing.T, mensajes ...string) (*imapclient.Client, io.Closer) {
t.Helper()
mem := imapmemserver.New()
user := imapmemserver.NewUser("soporte", "secreta")
if err := user.Create("INBOX", nil); err != nil {
t.Fatal(err)
}
mem.AddUser(user)
srv := imapserver.New(&imapserver.Options{
NewSession: func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
return mem.NewSession(), nil, nil
},
InsecureAuth: true,
})
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go srv.Serve(ln)
c, err := imapclient.DialInsecure(ln.Addr().String(), nil)
if err != nil {
t.Fatal(err)
}
if err := c.Login("soporte", "secreta").Wait(); err != nil {
t.Fatal(err)
}
for _, m := range mensajes {
ac := c.Append("INBOX", int64(len(m)), nil)
if _, err := ac.Write([]byte(m)); err != nil {
t.Fatal(err)
}
if err := ac.Close(); err != nil {
t.Fatal(err)
}
if _, err := ac.Wait(); err != nil {
t.Fatal(err)
}
}
return c, ln
}
const correoCrudo = "From: Juan <juan@cliente.com>\r\n" +
"Subject: No puedo entrar\r\n" +
"Message-Id: <uno@cliente.com>\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" +
"La contrasena no me sirve.\r\n"
// Este test existe por un bug concreto: la búsqueda usaba Search (números de
// secuencia) y después pedía UIDs, así que la lista salía siempre vacía y el
// buzón no se leía nunca, sin un solo error en el log.
func TestBajarNoLeidosDevuelveLosCorreos(t *testing.T) {
c, ln := servidorDePrueba(t, correoCrudo)
defer ln.Close()
defer c.Close()
correos, err := BajarNoLeidos(c, "INBOX", 0)
if err != nil {
t.Fatalf("BajarNoLeidos: %v", err)
}
if len(correos) != 1 {
t.Fatalf("esperaba 1 correo sin leer, hay %d", len(correos))
}
if correos[0].UID == 0 {
t.Error("el UID vino en cero: sin él no se puede marcar como leído")
}
if got := correos[0].Correo.Subject; got != "No puedo entrar" {
t.Errorf("Subject = %q", got)
}
if !strings.Contains(correos[0].Correo.Texto, "contrasena no me sirve") {
t.Errorf("Texto = %q", correos[0].Correo.Texto)
}
}
// Y este, por el otro lado del mismo problema: si marcar como leído no funciona,
// el mismo correo abre un ticket nuevo cada 2 minutos para siempre.
func TestMarcarLeidoSacaElCorreoDeLaProximaCorrida(t *testing.T) {
c, ln := servidorDePrueba(t, correoCrudo)
defer ln.Close()
defer c.Close()
correos, err := BajarNoLeidos(c, "INBOX", 0)
if err != nil || len(correos) != 1 {
t.Fatalf("BajarNoLeidos: %v (%d correos)", err, len(correos))
}
if err := MarcarLeido(c, correos[0].UID); err != nil {
t.Fatalf("MarcarLeido: %v", err)
}
otra, err := BajarNoLeidos(c, "INBOX", 0)
if err != nil {
t.Fatalf("segunda pasada: %v", err)
}
if len(otra) != 0 {
t.Errorf("el correo ya leído volvió a aparecer (%d)", len(otra))
}
}
// Peek: bajarlo sin procesarlo no lo debe marcar, o un fallo a mitad de camino
// perdería el correo para siempre.
func TestBajarNoLeidosNoMarcaSolo(t *testing.T) {
c, ln := servidorDePrueba(t, correoCrudo)
defer ln.Close()
defer c.Close()
if _, err := BajarNoLeidos(c, "INBOX", 0); err != nil {
t.Fatal(err)
}
otra, err := BajarNoLeidos(c, "INBOX", 0)
if err != nil {
t.Fatal(err)
}
if len(otra) != 1 {
t.Errorf("el correo debería seguir sin leer, hay %d", len(otra))
}
}
var _ = imap.FlagSeen
func TestVentanaDeAntiguedad(t *testing.T) {
ahora := time.Now()
casos := []struct {
nombre string
recibido time.Time
horas int
want bool
}{
{"de hace una hora, ventana 12", ahora.Add(-1 * time.Hour), 12, true},
{"de hace 13 horas, ventana 12", ahora.Add(-13 * time.Hour), 12, false},
{"viejísimo, sin ventana", ahora.AddDate(-2, 0, 0), 0, true},
{"sin fecha del servidor", time.Time{}, 12, true},
}
for _, c := range casos {
if got := dentroDeLaVentana(c.recibido, c.horas); got != c.want {
t.Errorf("%s: dentroDeLaVentana = %v, want %v", c.nombre, got, c.want)
}
}
// El SINCE que se le pide al servidor tiene que ser MÁS amplio que la
// ventana real: IMAP compara solo la fecha, y afinar de más pierde correos.
c := criterioNoLeidos(12)
if !c.Since.Before(ahora.Add(-12 * time.Hour)) {
t.Errorf("Since = %v, debería ser anterior al corte real", c.Since)
}
if !criterioNoLeidos(0).Since.IsZero() {
t.Error("sin ventana no debería mandarse SINCE")
}
}
+422
View File
@@ -0,0 +1,422 @@
package services
import (
"encoding/base64"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"strings"
"sync"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// El poller corre por cron; este lock evita que dos corridas se pisen si una
// tarda más que el intervalo (buzón grande, servidor lento).
var imapEnCurso sync.Mutex
// RevisarBuzonSoporte lee los correos sin leer del buzón configurado y los
// convierte en tickets. Cada correo procesado se marca como \Seen, que es lo
// que evita volver a leerlo; la deduplicación por Message-Id es el segundo
// cinturón por si el marcado falla.
func RevisarBuzonSoporte() {
cfg, err := models.GetSoporteWebhookActivo()
if err != nil || cfg == nil || !cfg.ImapActivo || cfg.ImapHost == "" {
return
}
if !imapEnCurso.TryLock() {
log.Printf("[SoporteIMAP] corrida anterior todavía en curso, se salta esta")
return
}
defer imapEnCurso.Unlock()
encontrados, procesados, err := revisarBuzon(cfg)
if err != nil {
log.Printf("[SoporteIMAP] %v", err)
return
}
if encontrados > 0 {
log.Printf("[SoporteIMAP] %d sin leer, %d procesado(s)", encontrados, procesados)
}
}
// RevisarBuzonSoporteConDetalle es la versión que usa el botón "Revisar buzón
// ahora": devuelve los números para poder mostrarlos, en vez de solo loguearlos.
func RevisarBuzonSoporteConDetalle() (encontrados, procesados int, err error) {
cfg, err := models.GetSoporteWebhookActivo()
if err != nil || cfg == nil {
return 0, 0, fmt.Errorf("no hay configuración de soporte guardada")
}
if !cfg.ImapActivo {
return 0, 0, fmt.Errorf("la lectura del buzón está desactivada: tildá \"Leer el buzón cada 2 minutos\" y guardá")
}
if cfg.ImapHost == "" {
return 0, 0, fmt.Errorf("falta el servidor IMAP en la configuración")
}
if !imapEnCurso.TryLock() {
return 0, 0, fmt.Errorf("hay una revisión en curso, probá en unos segundos")
}
defer imapEnCurso.Unlock()
return revisarBuzon(cfg)
}
// ProbarConexionImap valida credenciales sin procesar nada — lo usa el botón
// "Probar" de la vista de configuración.
func ProbarConexionImap(cfg *models.SoporteWebhookConfig) (string, error) {
c, err := conectarImap(cfg)
if err != nil {
return "", err
}
defer c.Close()
carpeta := cfg.ImapCarpeta
if carpeta == "" {
carpeta = "INBOX"
}
datos, err := c.Select(carpeta, &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
return "", fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
}
sinLeer := 0
if r, err := c.UIDSearch(criterioNoLeidos(cfg.ImapHorasAtras), &imap.SearchOptions{ReturnAll: true}).Wait(); err == nil {
sinLeer = len(r.AllUIDs())
}
_ = c.Logout().Wait()
// El conteo del servidor es por día; el corte por hora se aplica al leer,
// así que este número puede ser un poco mayor que el que se va a procesar.
ventana := "sin leer"
if cfg.ImapHorasAtras > 0 {
ventana = fmt.Sprintf("sin leer de las últimas ~%d horas", cfg.ImapHorasAtras)
}
return fmt.Sprintf("Conexión correcta. %s: %d mensajes, %d %s.", carpeta, datos.NumMessages, sinLeer, ventana), nil
}
func conectarImap(cfg *models.SoporteWebhookConfig) (*imapclient.Client, error) {
pass, err := DescifrarSecretoUmind(cfg.ImapPasswordEnc)
if err != nil || pass == "" {
return nil, fmt.Errorf("no hay contraseña IMAP guardada")
}
port := cfg.ImapPort
if port == 0 {
port = 993
}
addr := fmt.Sprintf("%s:%d", cfg.ImapHost, port)
var c *imapclient.Client
if strings.EqualFold(cfg.ImapEncryption, "starttls") {
c, err = imapclient.DialStartTLS(addr, nil)
} else {
c, err = imapclient.DialTLS(addr, nil)
}
if err != nil {
return nil, fmt.Errorf("no se pudo conectar a %s: %w", addr, err)
}
if err := c.Login(cfg.ImapUsername, pass).Wait(); err != nil {
c.Close()
return nil, fmt.Errorf("login IMAP rechazado para %s: %w", cfg.ImapUsername, err)
}
return c, nil
}
func revisarBuzon(cfg *models.SoporteWebhookConfig) (int, int, error) {
c, err := conectarImap(cfg)
if err != nil {
return 0, 0, err
}
defer c.Close()
correos, err := BajarNoLeidos(c, cfg.ImapCarpeta, cfg.ImapHorasAtras)
if err != nil {
return 0, 0, err
}
if len(correos) == 0 {
return 0, 0, nil
}
fallosAlMarcar := 0
var descartes []string
procesados := 0
for _, correo := range correos {
ok, descarte := IngestarCorreoSoporteConDetalle(cfg, correo.Correo)
if ok {
procesados++
} else if descarte != "" {
descartes = append(descartes, descarte)
}
// Se marca leído aunque se haya ignorado por duplicado: si no, se
// vuelve a bajar en cada corrida para siempre.
if err := MarcarLeido(c, correo.UID); err != nil {
log.Printf("[SoporteIMAP] no se pudo marcar leído el uid=%v: %v", correo.UID, err)
fallosAlMarcar++
}
}
ultimosDescartes.guardar(descartes)
if fallosAlMarcar > 0 {
return len(correos), procesados, fmt.Errorf(
"se procesaron %d correo(s), pero %d no se pudieron marcar como leídos en el servidor: van a volver a leerse",
procesados, fallosAlMarcar)
}
return len(correos), procesados, nil
}
// ultimosDescartes guarda lo que el filtro dejó afuera en la última corrida,
// para poder mostrarlo en la pantalla de configuración. En memoria a propósito:
// es información de diagnóstico de hace un rato, no algo que valga una tabla.
var ultimosDescartes = &descartesRecientes{}
type descartesRecientes struct {
mu sync.Mutex
items []string
}
func (d *descartesRecientes) guardar(items []string) {
d.mu.Lock()
defer d.mu.Unlock()
if len(items) > 0 {
d.items = items
}
}
// UltimosCorreosDescartados devuelve los descartes de la última corrida que
// tuvo alguno.
func UltimosCorreosDescartados() []string {
ultimosDescartes.mu.Lock()
defer ultimosDescartes.mu.Unlock()
return append([]string(nil), ultimosDescartes.items...)
}
// criterioNoLeidos arma la búsqueda: no leídos y, si hay ventana configurada,
// recibidos dentro de ella.
//
// SINCE de IMAP compara solo la fecha, no la hora, así que acá se pide un día
// de más y el corte fino por hora se hace después contra la fecha real de cada
// mensaje (ver dentroDeLaVentana). Pedirle al servidor que filtre de más sería
// perder correos del borde.
func criterioNoLeidos(horasAtras int) *imap.SearchCriteria {
c := &imap.SearchCriteria{NotFlag: []imap.Flag{imap.FlagSeen}}
if horasAtras > 0 {
c.Since = time.Now().Add(-time.Duration(horasAtras)*time.Hour).AddDate(0, 0, -1)
}
return c
}
// dentroDeLaVentana es el corte por hora que IMAP no puede hacer.
func dentroDeLaVentana(recibido time.Time, horasAtras int) bool {
if horasAtras <= 0 || recibido.IsZero() {
return true
}
return recibido.After(time.Now().Add(-time.Duration(horasAtras) * time.Hour))
}
// CorreoConUID es un correo del buzón junto con su UID, que es lo que hace
// falta después para marcarlo como leído.
type CorreoConUID struct {
UID imap.UID
Correo CorreoSoporte
}
// BajarNoLeidos abre la carpeta, busca los mensajes sin leer y los devuelve ya
// parseados. No los marca: eso pasa recién cuando se procesaron.
func BajarNoLeidos(c *imapclient.Client, carpeta string, horasAtras int) ([]CorreoConUID, error) {
if carpeta == "" {
carpeta = "INBOX"
}
if _, err := c.Select(carpeta, nil).Wait(); err != nil {
return nil, fmt.Errorf("no se pudo abrir la carpeta %q: %w", carpeta, err)
}
// UIDSearch y no Search: Search devuelve números de secuencia, y de ahí no
// salen UIDs — la búsqueda "encontraba" mensajes y la lista quedaba vacía.
buscados, err := c.UIDSearch(criterioNoLeidos(horasAtras), &imap.SearchOptions{ReturnAll: true}).Wait()
if err != nil {
return nil, fmt.Errorf("búsqueda de no leídos falló: %w", err)
}
uids := buscados.AllUIDs()
if len(uids) == 0 {
return nil, nil
}
// ponytail: tope por corrida para no atragantarse con un buzón que nunca
// se leyó. Los que sobran quedan sin leer y entran en la corrida siguiente.
const maxPorCorrida = 50
if len(uids) > maxPorCorrida {
uids = uids[:maxPorCorrida]
}
// Peek para que el correo quede leído solo si llegamos a procesarlo.
msgs, err := c.Fetch(imap.UIDSetNum(uids...), &imap.FetchOptions{
InternalDate: true,
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
}).Collect()
if err != nil {
return nil, fmt.Errorf("descarga de correos falló: %w", err)
}
var out []CorreoConUID
for _, m := range msgs {
if !dentroDeLaVentana(m.InternalDate, horasAtras) {
continue
}
var crudo []byte
for _, b := range m.BodySection {
crudo = b.Bytes
break
}
if len(crudo) == 0 {
continue
}
correo, err := parsearCorreo(crudo)
if err != nil {
log.Printf("[SoporteIMAP] no se pudo leer un correo (uid=%v): %v", m.UID, err)
continue
}
out = append(out, CorreoConUID{UID: m.UID, Correo: correo})
}
return out, nil
}
// MarcarLeido pone el flag \Seen, que es lo que evita releer el mismo correo.
func MarcarLeido(c *imapclient.Client, uid imap.UID) error {
return c.Store(imap.UIDSetNum(uid), &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Silent: true,
Flags: []imap.Flag{imap.FlagSeen},
}, nil).Close()
}
// parsearCorreo saca remitente, asunto y cuerpo de texto de un mensaje RFC822.
func parsearCorreo(crudo []byte) (CorreoSoporte, error) {
msg, err := mail.ReadMessage(strings.NewReader(string(crudo)))
if err != nil {
return CorreoSoporte{}, err
}
dec := new(mime.WordDecoder)
decodificar := func(s string) string {
if out, err := dec.DecodeHeader(s); err == nil {
return out
}
return s
}
from := decodificar(msg.Header.Get("From"))
nombre := ExtraerNombre(from)
if dir, err := mail.ParseAddress(from); err == nil && dir.Name != "" {
nombre = dir.Name
}
cuerpo, err := cuerpoDeTexto(msg.Header.Get("Content-Type"), msg.Body)
if err != nil {
return CorreoSoporte{}, err
}
return CorreoSoporte{
From: from,
FromName: nombre,
Subject: decodificar(msg.Header.Get("Subject")),
Texto: limpiarCitas(cuerpo),
MessageID: strings.TrimSpace(msg.Header.Get("Message-Id")),
Automatico: esCorreoAutomatico(msg.Header),
}, nil
}
// cuerpoDeTexto devuelve el text/plain del correo; si es multipart baja por las
// partes hasta encontrarlo y cae al HTML solo si no hay texto plano.
func cuerpoDeTexto(contentType string, cuerpo io.Reader) (string, error) {
medio, params, err := mime.ParseMediaType(contentType)
if err != nil {
medio = "text/plain"
params = map[string]string{}
}
if !strings.HasPrefix(medio, "multipart/") {
b, err := io.ReadAll(io.LimitReader(cuerpo, 1<<20))
if err != nil {
return "", err
}
return string(b), nil
}
frontera := params["boundary"]
if frontera == "" {
return "", fmt.Errorf("multipart sin boundary")
}
lector := multipart.NewReader(cuerpo, frontera)
var html string
for {
parte, err := lector.NextPart()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
tipoParte, _, _ := mime.ParseMediaType(parte.Header.Get("Content-Type"))
if strings.HasPrefix(tipoParte, "multipart/") {
anidado, err := cuerpoDeTexto(parte.Header.Get("Content-Type"), parte)
if err == nil && anidado != "" {
return anidado, nil
}
continue
}
if tipoParte != "text/plain" && tipoParte != "text/html" {
continue
}
var lect io.Reader = io.LimitReader(parte, 1<<20)
switch strings.ToLower(parte.Header.Get("Content-Transfer-Encoding")) {
case "quoted-printable":
lect = quotedprintable.NewReader(lect)
case "base64":
lect = base64.NewDecoder(base64.StdEncoding, lect)
}
b, err := io.ReadAll(lect)
if err != nil {
continue
}
if tipoParte == "text/plain" {
return string(b), nil
}
html = string(b)
}
return html, nil
}
// limpiarCitas corta el correo en la primera línea citada o en el separador
// típico de respuesta, para que el ticket no repita todo el hilo anterior.
func limpiarCitas(texto string) string {
lineas := strings.Split(strings.ReplaceAll(texto, "\r\n", "\n"), "\n")
var out []string
for _, l := range lineas {
t := strings.TrimSpace(l)
if strings.HasPrefix(t, ">") ||
strings.HasPrefix(t, "-----Original Message-----") ||
strings.HasPrefix(t, "-----Mensaje original-----") ||
(strings.HasPrefix(t, "El ") && strings.HasSuffix(t, "escribió:")) ||
(strings.HasPrefix(t, "On ") && strings.HasSuffix(t, "wrote:")) {
break
}
out = append(out, l)
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
// esCorreoAutomatico reconoce newsletters y notificaciones de máquina por sus
// encabezados estándar. Es gratis y no se equivoca, así que va antes que
// cualquier modelo.
func esCorreoAutomatico(h mail.Header) bool {
if h.Get("List-Unsubscribe") != "" || h.Get("List-Id") != "" {
return true
}
switch strings.ToLower(strings.TrimSpace(h.Get("Precedence"))) {
case "bulk", "list", "junk":
return true
}
if a := strings.ToLower(strings.TrimSpace(h.Get("Auto-Submitted"))); a != "" && a != "no" {
return true
}
return false
}
+56
View File
@@ -0,0 +1,56 @@
package services
import "testing"
func TestParsearCorreoMultipartQuotedPrintable(t *testing.T) {
crudo := "From: =?UTF-8?Q?Juan_P=C3=A9rez?= <juan@cliente.com>\r\n" +
"Subject: =?UTF-8?Q?No_puedo_entrar_a_la_plataforma?=\r\n" +
"Message-Id: <abc123@cliente.com>\r\n" +
"Content-Type: multipart/alternative; boundary=\"XX\"\r\n" +
"\r\n" +
"--XX\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"Content-Transfer-Encoding: quoted-printable\r\n" +
"\r\n" +
"Hola, la contrase=C3=B1a no me sirve.\r\n" +
"\r\n" +
"El 3 de marzo, Soporte escribi=C3=B3:\r\n" +
"> proba de nuevo\r\n" +
"--XX\r\n" +
"Content-Type: text/html; charset=UTF-8\r\n" +
"\r\n" +
"<p>ignorame</p>\r\n" +
"--XX--\r\n"
c, err := parsearCorreo([]byte(crudo))
if err != nil {
t.Fatalf("parsearCorreo: %v", err)
}
if c.FromName != "Juan Pérez" {
t.Errorf("FromName = %q, want %q", c.FromName, "Juan Pérez")
}
if ExtraerEmail(c.From) != "juan@cliente.com" {
t.Errorf("From = %q", c.From)
}
if c.Subject != "No puedo entrar a la plataforma" {
t.Errorf("Subject = %q", c.Subject)
}
if c.MessageID != "<abc123@cliente.com>" {
t.Errorf("MessageID = %q", c.MessageID)
}
// Cuerpo decodificado, sin la cita del hilo anterior ni el HTML.
if c.Texto != "Hola, la contraseña no me sirve." {
t.Errorf("Texto = %q", c.Texto)
}
}
func TestParsearCorreoPlano(t *testing.T) {
crudo := "From: ana@cliente.com\r\nSubject: Consulta\r\n\r\nHola\r\n"
c, err := parsearCorreo([]byte(crudo))
if err != nil {
t.Fatalf("parsearCorreo: %v", err)
}
if c.Texto != "Hola" || c.Subject != "Consulta" {
t.Errorf("got %+v", c)
}
}
+288
View File
@@ -0,0 +1,288 @@
package services
import (
"errors"
"fmt"
"log"
"regexp"
"strconv"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"gorm.io/gorm"
)
// CorreoSoporte es un correo entrante ya normalizado, sin importar por dónde
// llegó (webhook de SendGrid/Mailgun o buzón IMAP). Los dos caminos terminan
// acá para que enhebrado, deduplicación y auto-respuesta se comporten igual.
type CorreoSoporte struct {
From string // puede venir como "Nombre <mail@x.com>"
FromName string
Subject string
Texto string
MessageID string
// Automatico marca los correos masivos o generados por una máquina, según
// sus propios encabezados (List-Unsubscribe, Precedence, Auto-Submitted).
Automatico bool
}
var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`)
// IngestarCorreoSoporte convierte un correo en ticket nuevo o en respuesta a un
// ticket existente. Devuelve true si se procesó algo (false = ignorado por
// duplicado o por venir vacío).
func IngestarCorreoSoporte(cfg *models.SoporteWebhookConfig, e CorreoSoporte) bool {
ok, _ := IngestarCorreoSoporteConDetalle(cfg, e)
return ok
}
// IngestarCorreoSoporteConDetalle es la misma ingesta, pero además dice por qué
// se descartó un correo. Un descarte que solo va al log es invisible: si el
// filtro se come el correo de un cliente, nadie se entera.
func IngestarCorreoSoporteConDetalle(cfg *models.SoporteWebhookConfig, e CorreoSoporte) (procesado bool, descarte string) {
if strings.TrimSpace(e.From) == "" {
log.Printf("[Soporte] Correo sin remitente, ignorado (asunto=%q)", e.Subject)
return false, ""
}
// Un correo sin asunto es raro pero existe, y perderlo en silencio es peor
// que abrir un ticket con un título feo.
if strings.TrimSpace(e.Subject) == "" {
e.Subject = "(sin asunto)"
}
// Deduplicación: el proveedor puede reintentar la entrega, y el poller IMAP
// puede releer un correo si falló el marcado como leído.
if models.EmailMessageIDYaProcesado(e.MessageID) {
log.Printf("[Soporte] Correo duplicado ignorado (message_id=%s)", e.MessageID)
return false, ""
}
fromEmail := ExtraerEmail(e.From)
fromName := e.FromName
if fromName == "" {
fromName = ExtraerNombre(e.From)
}
if fromName == "" {
fromName = fromEmail
}
contenido := strings.TrimSpace(e.Texto)
if len(contenido) > 5000 {
contenido = contenido[:5000]
}
// Enhebrado: si el asunto trae "[Ticket #N]" (lo agregamos nosotros en el
// auto-ack) y ese ticket es del mismo remitente, es una respuesta — se
// agrega como mensaje en vez de abrir un ticket nuevo.
if hilo := buscarTicketDeHilo(fromEmail, e.Subject); hilo != nil {
msg := &models.TicketMensaje{
TicketID: hilo.ID,
Contenido: contenido,
EsAdmin: false,
AutorNombre: fromName,
MessageID: e.MessageID,
}
if err := models.CreateTicketMensaje(msg); err != nil {
if esCorreoDuplicado(err) {
log.Printf("[Soporte] El correo %s ya estaba en el ticket #%d", e.MessageID, hilo.ID)
return false, ""
}
log.Printf("[Soporte] Error agregando mensaje al ticket #%d: %v", hilo.ID, err)
return false, ""
}
if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" {
_ = models.UpdateTicketEstado(hilo.ID, "abierto")
}
log.Printf("[Soporte] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail)
notificarTicketDeCorreo(hilo, contenido, true)
return true, ""
}
ticket := &models.ProyectoTicket{
AutorNombre: fromName,
EmailFrom: fromEmail,
Titulo: e.Subject,
Descripcion: contenido,
Estado: "abierto",
Origen: "email",
MessageID: e.MessageID,
}
// De quién es: sin esto un ticket de correo es un texto suelto que no se
// puede cruzar con nada. Si no matchea nadie, queda como contacto externo.
// Va antes del filtro a propósito: saber quién escribe cambia si se filtra.
resolverRemitente(ticket, fromEmail)
// Filtro y triage con IA: solo para tickets nuevos y solo para desconocidos.
// Una respuesta a un hilo es soporte por definición, y a un cliente
// registrado no se le descarta el correo por lo que opine un modelo.
conocido := ticket.ClienteID != nil || ticket.PortalUserID != nil
if cfg != nil && cfg.ClasificarConIA && !conocido {
cl := ClasificarCorreoSoporte(e, cfg.ContextoNegocio)
if !cl.EsSoporte {
motivo := fmt.Sprintf("%q de %s — %s (%s)", e.Subject, fromEmail, cl.Motivo, cl.Categoria)
log.Printf("[Soporte] Descartado por el filtro: %s", motivo)
return false, motivo
}
ticket.Categoria = cl.Categoria
ticket.Prioridad = cl.Prioridad
}
if cfg != nil && cfg.AsignarA != nil {
ticket.AsignadoA = cfg.AsignarA
}
if err := models.CreateProyectoTicket(ticket); err != nil {
// El índice único sobre message_id es la última defensa contra el mismo
// correo entrando dos veces. Si salta, no es un error: es que ya estaba.
if esCorreoDuplicado(err) {
log.Printf("[Soporte] Correo duplicado rechazado por la base (message_id=%s)", e.MessageID)
return false, ""
}
log.Printf("[Soporte] Error creando ticket: %v", err)
return false, ""
}
log.Printf("[Soporte] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
notificarTicketDeCorreo(ticket, contenido, false)
if cfg != nil && cfg.ResponderAuto {
SendSoporteAutoRespuesta(ticket)
} else {
log.Printf("[Soporte] Ticket #%d sin acuse al cliente: la respuesta automática está desactivada", ticket.ID)
}
return true, ""
}
// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta:
// primero por el token "[Ticket #N]" en el asunto (verificando que sea del mismo
// remitente, para que nadie pueda inyectar mensajes en el ticket de otro
// adivinando el número), y si no hay token, por el último ticket abierto de ese
// remitente cuando el asunto tiene pinta de respuesta (Re:/RE:/Fwd:).
func buscarTicketDeHilo(fromEmail, subject string) *models.ProyectoTicket {
if m := ticketRefRe.FindStringSubmatch(subject); m != nil {
id, _ := strconv.ParseUint(m[1], 10, 32)
if id > 0 {
t, err := models.GetTicketByID(uint(id))
if err == nil && strings.EqualFold(t.EmailFrom, fromEmail) {
return t
}
}
}
lower := strings.ToLower(strings.TrimSpace(subject))
if strings.HasPrefix(lower, "re:") || strings.HasPrefix(lower, "fwd:") || strings.HasPrefix(lower, "fw:") {
if t, err := models.GetUltimoTicketAbiertoPorEmail(fromEmail); err == nil {
return t
}
}
return nil
}
// ExtraerEmail saca la dirección de un "Nombre <mail@x.com>".
func ExtraerEmail(s string) string {
s = strings.TrimSpace(s)
if idx := strings.LastIndex(s, "<"); idx >= 0 {
s = s[idx+1:]
}
if idx := strings.LastIndex(s, ">"); idx >= 0 {
s = s[:idx]
}
return strings.TrimSpace(s)
}
// ExtraerNombre saca el nombre de un "Nombre <mail@x.com>" ("" si no trae).
func ExtraerNombre(s string) string {
s = strings.TrimSpace(s)
if idx := strings.Index(s, "<"); idx >= 0 {
return strings.TrimSpace(s[:idx])
}
return ""
}
// notificarTicketDeCorreo avisa al staff de un correo entrante por los canales
// que estén prendidos en /app/notif-config (in-app, correo y Telegram), con el
// mismo formato que los tickets del portal.
//
// El cuerpo que viaja en la notificación es el resumen, no el correo entero:
// el ticket guarda el texto completo igual.
func notificarTicketDeCorreo(ticket *models.ProyectoTicket, contenido string, esRespuesta bool) {
resumen := ResumirTextoSoporte(contenido)
origen := "Correo"
if ticket.EmailFrom != "" {
origen = "Correo · " + ticket.EmailFrom
}
if esRespuesta {
DispatchTicketRespuestaCliente(ticket, resumen, origen)
return
}
// Copia para no pisar el texto completo que ya se guardó en el ticket.
paraNotificar := *ticket
paraNotificar.Descripcion = resumen
if models.GetNotifConfig("ticket_nuevo", "admin") == nil {
// Sin configuración de notificaciones se mantiene lo de siempre: correo
// al admin. Si no, activar el módulo dejaría a alguien sin avisos.
SendSoporteNotifAdmin(&paraNotificar)
return
}
DispatchTicketNuevo(&paraNotificar, nil, origen)
}
// ResumirTextoSoporte deja el correo en algo que se pueda leer de un vistazo en
// Telegram. Los correos cortos van tal cual: pedirle a la IA que resuma tres
// renglones es gastar una llamada para decir lo mismo. Los largos —hilos
// reenviados, capturas pegadas— sí se resumen, y si la IA falla se recorta.
func ResumirTextoSoporte(texto string) string {
t := strings.TrimSpace(texto)
if len([]rune(t)) <= 700 {
return t
}
resumen, err := CompletarTextoIA("ia",
"Resumís correos de soporte para avisarle al equipo por Telegram. "+
"Máximo 3 renglones. Decí qué pide o reporta la persona y, si los hay, "+
"incluí datos concretos (número de factura, pedido, fecha, monto). "+
"Sin saludos, sin despedidas, sin repetir el asunto, sin inventar nada.",
t)
if err != nil {
log.Printf("[Soporte] No se pudo resumir el correo, se manda recortado: %v", err)
return string([]rune(t)[:700]) + "…"
}
if r := strings.TrimSpace(resumen); r != "" {
return r
}
return string([]rune(t)[:700]) + "…"
}
// esCorreoDuplicado reconoce el rechazo del índice único de message_id.
func esCorreoDuplicado(err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "duplicate key value")
}
// resolverRemitente ata el ticket a quien escribió: primero por usuario del
// portal (que además le deja ver el ticket desde su portal) y si no, por el
// correo del cliente. Solo coincidencia exacta de dirección — ver el comentario
// de models.GetClientePorEmail sobre por qué no se busca por dominio.
func resolverRemitente(ticket *models.ProyectoTicket, email string) {
if email == "" {
return
}
if pu, err := models.GetPortalUserByEmail(email); err == nil && pu != nil {
id := pu.ID
ticket.PortalUserID = &id
if ids := models.GetClienteIDsForPortalUser(pu); len(ids) == 1 {
ticket.ClienteID = &ids[0]
}
return
}
if cli, err := models.GetClientePorEmail(email); err == nil && cli != nil {
id := cli.ID
ticket.ClienteID = &id
}
}
@@ -1,4 +1,4 @@
package controllers package services
import ( import (
"testing" "testing"
@@ -18,9 +18,9 @@ func TestExtractEmailSimple(t *testing.T) {
{"<onlybrackets>", "onlybrackets"}, {"<onlybrackets>", "onlybrackets"},
} }
for _, tt := range tests { for _, tt := range tests {
got := extractEmail(tt.input) got := ExtraerEmail(tt.input)
if got != tt.want { if got != tt.want {
t.Errorf("extractEmail(%q) = %q, want %q", tt.input, got, tt.want) t.Errorf("ExtraerEmail(%q) = %q, want %q", tt.input, got, tt.want)
} }
} }
} }
@@ -38,9 +38,9 @@ func TestExtractNameSimple(t *testing.T) {
{"", ""}, {"", ""},
} }
for _, tt := range tests { for _, tt := range tests {
got := extractName(tt.input) got := ExtraerNombre(tt.input)
if got != tt.want { if got != tt.want {
t.Errorf("extractName(%q) = %q, want %q", tt.input, got, tt.want) t.Errorf("ExtraerNombre(%q) = %q, want %q", tt.input, got, tt.want)
} }
} }
} }
@@ -57,24 +57,36 @@ func TestExtractEmailRealWorld(t *testing.T) {
{"", "", ""}, {"", "", ""},
} }
for _, tt := range inputs { for _, tt := range inputs {
gotMail := extractEmail(tt.full) gotMail := ExtraerEmail(tt.full)
gotName := extractName(tt.full) gotName := ExtraerNombre(tt.full)
if gotMail != tt.mail { if gotMail != tt.mail {
t.Errorf("extractEmail(%q) = %q, want %q", tt.full, gotMail, tt.mail) t.Errorf("ExtraerEmail(%q) = %q, want %q", tt.full, gotMail, tt.mail)
} }
if gotName != tt.name { if gotName != tt.name {
t.Errorf("extractName(%q) = %q, want %q", tt.full, gotName, tt.name) t.Errorf("ExtraerNombre(%q) = %q, want %q", tt.full, gotName, tt.name)
} }
} }
} }
func TestExtractEmailEdgeCases(t *testing.T) { func TestExtractEmailEdgeCases(t *testing.T) {
// Formato RFC 5322 con nombre y ángulos // Formato RFC 5322 con nombre y ángulos
if got := extractEmail("a<b@c.com>"); got != "b@c.com" { if got := ExtraerEmail("a<b@c.com>"); got != "b@c.com" {
t.Errorf("extractEmail('a<b@c.com>') = %q, want 'b@c.com'", got) t.Errorf("ExtraerEmail('a<b@c.com>') = %q, want 'b@c.com'", got)
} }
// Múltiples brackets — usa el último par // Múltiples brackets — usa el último par
if got := extractEmail("<a><b@c.com>"); got != "b@c.com" { if got := ExtraerEmail("<a><b@c.com>"); got != "b@c.com" {
t.Errorf("extractEmail('<a><b@c.com>') = %q, want 'b@c.com'", got) t.Errorf("ExtraerEmail('<a><b@c.com>') = %q, want 'b@c.com'", got)
}
}
func TestResumirTextoSoporteCortoNoLlamaALaIA(t *testing.T) {
// Sin base de datos, cualquier llamada a la IA explota: que este caso pase
// es la prueba de que un correo corto no la usa.
corto := "Hola, no me llega la factura de marzo. Gracias."
if got := ResumirTextoSoporte(corto); got != corto {
t.Errorf("ResumirTextoSoporte devolvió %q, esperaba el texto tal cual", got)
}
if got := ResumirTextoSoporte(" " + corto + "\n"); got != corto {
t.Errorf("no recortó los espacios: %q", got)
} }
} }
+111 -53
View File
@@ -6,6 +6,7 @@ import (
"log" "log"
"net/smtp" "net/smtp"
"strings" "strings"
"sync"
"github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/app"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
@@ -14,6 +15,13 @@ import (
// soporteSendMail envía un correo usando la configuración SMTP del webhook de soporte. // soporteSendMail envía un correo usando la configuración SMTP del webhook de soporte.
// Si no hay SMTP configurado, usa app.Http.Mail como fallback. // Si no hay SMTP configurado, usa app.Http.Mail como fallback.
func soporteSendMail(to, subject, htmlBody string) error { func soporteSendMail(to, subject, htmlBody string) error {
// Sin base no hay configuración que leer: consultarla igual revienta con un
// nil, y como el envío corre en su propia goroutine se lleva puesto todo el
// proceso. Es lo que hacía que la suite de este paquete no se pudiera
// correr entera.
if app.Http.Database.DB == nil {
return fmt.Errorf("no hay base de datos para leer la configuración de correo")
}
cfg, err := models.GetSoporteWebhookActivo() cfg, err := models.GetSoporteWebhookActivo()
if err != nil || cfg == nil || cfg.SmtpHost == "" { if err != nil || cfg == nil || cfg.SmtpHost == "" {
return app.Http.Mail.Send(to, subject, htmlBody) return app.Http.Mail.Send(to, subject, htmlBody)
@@ -33,61 +41,87 @@ func soporteSendMail(to, subject, htmlBody string) error {
if port == 0 { if port == 0 {
port = 587 port = 587
} }
auth := smtp.PlainAuth("", cfg.SmtpUsername, cfg.SmtpPassword, cfg.SmtpHost)
msg := []byte(fmt.Sprintf("From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", fromName, from, to, subject, htmlBody))
addr := fmt.Sprintf("%s:%d", cfg.SmtpHost, port) addr := fmt.Sprintf("%s:%d", cfg.SmtpHost, port)
enc := strings.ToLower(cfg.SmtpEncryption) msg := []byte(fmt.Sprintf(
if enc == "tls" { "From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s",
tlsCfg := &tls.Config{ServerName: cfg.SmtpHost} fromName, from, to, subject, htmlBody))
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return fmt.Errorf("soporte SMTP TLS: %w", err)
}
client, err := smtp.NewClient(conn, cfg.SmtpHost)
if err != nil {
conn.Close()
return fmt.Errorf("soporte SMTP client: %w", err)
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return fmt.Errorf("soporte SMTP auth: %w", err)
}
if err = client.Mail(from); err != nil {
return err
}
if err = client.Rcpt(to); err != nil {
return err
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write(msg)
if err != nil {
return err
}
return w.Close()
}
if enc == "starttls" {
tlsCfg := &tls.Config{ServerName: cfg.SmtpHost}
conn, err := smtp.Dial(addr)
if err != nil {
goto fallback
}
if err = conn.StartTLS(tlsCfg); err != nil {
conn.Close()
goto fallback
}
if err = conn.Auth(auth); err != nil {
conn.Close()
return fmt.Errorf("soporte SMTP STARTTLS auth: %w", err)
}
return smtp.SendMail(addr, auth, from, []string{to}, msg)
}
return smtp.SendMail(addr, auth, from, []string{to}, msg)
fallback: if err := enviarPorSMTP(cfg, addr, from, to, msg); err != nil {
return app.Http.Mail.Send(to, subject, htmlBody) return fmt.Errorf("SMTP de soporte (%s): %w", addr, err)
}
return nil
}
// enviarPorSMTP abre una sola conexión, la asegura según la configuración,
// manda el mensaje y cierra con QUIT.
//
// Antes el camino STARTTLS abría una conexión, hacía StartTLS, autenticaba… y
// la descartaba para llamar a smtp.SendMail, que abre otra distinta: la primera
// quedaba colgada y el envío real salía por una conexión que podía no estar
// autenticada igual.
func enviarPorSMTP(cfg *models.SoporteWebhookConfig, addr, from, to string, msg []byte) error {
enc := strings.ToLower(strings.TrimSpace(cfg.SmtpEncryption))
var cliente *smtp.Client
var err error
if enc == "tls" || enc == "ssl" {
conn, errDial := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.SmtpHost})
if errDial != nil {
return fmt.Errorf("no se pudo conectar por TLS: %w", errDial)
}
cliente, err = smtp.NewClient(conn, cfg.SmtpHost)
if err != nil {
conn.Close()
return fmt.Errorf("saludo SMTP rechazado: %w", err)
}
} else {
cliente, err = smtp.Dial(addr)
if err != nil {
return fmt.Errorf("no se pudo conectar: %w", err)
}
if enc != "none" {
if err := cliente.StartTLS(&tls.Config{ServerName: cfg.SmtpHost}); err != nil {
cliente.Close()
return fmt.Errorf("STARTTLS rechazado: %w", err)
}
}
}
defer cliente.Close()
if cfg.SmtpUsername != "" {
if err := cliente.Auth(smtp.PlainAuth("", cfg.SmtpUsername, cfg.SmtpPassword, cfg.SmtpHost)); err != nil {
return fmt.Errorf("autenticación rechazada para %s: %w", cfg.SmtpUsername, err)
}
}
if err := cliente.Mail(from); err != nil {
return fmt.Errorf("el servidor rechazó el remitente %s: %w", from, err)
}
if err := cliente.Rcpt(to); err != nil {
return fmt.Errorf("el servidor rechazó el destinatario %s: %w", to, err)
}
w, err := cliente.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return fmt.Errorf("el servidor rechazó el mensaje: %w", err)
}
return cliente.Quit()
}
// ProbarEnvioSoporte manda un correo de prueba por el mismo camino que usa el
// acuse automático, y devuelve el error tal cual. Es la única forma de saber
// por qué no llega: el acuse real se manda en segundo plano.
func ProbarEnvioSoporte(destino string) error {
if strings.TrimSpace(destino) == "" {
return fmt.Errorf("indicá a qué dirección mandar la prueba")
}
cuerpo := `<p>Esto es una prueba del envío de soporte.</p>
<p>Si te llegó, el acuse automático de los tickets también va a salir por acá.</p>`
return soporteSendMail(destino, "Prueba de envío de soporte", cuerpo)
} }
// SendSoporteAutoRespuesta envía acuse de recibo automático al crear un ticket por email // SendSoporteAutoRespuesta envía acuse de recibo automático al crear un ticket por email
@@ -123,8 +157,10 @@ func SendSoporteAutoRespuesta(ticket *models.ProyectoTicket) {
go func() { go func() {
if err := soporteSendMail(ticket.EmailFrom, subject, htmlBody); err != nil { if err := soporteSendMail(ticket.EmailFrom, subject, htmlBody); err != nil {
log.Printf("[Soporte] Error enviando auto-respuesta a %s: %v", ticket.EmailFrom, err) log.Printf("[Soporte] Error enviando auto-respuesta a %s: %v", ticket.EmailFrom, err)
guardarErrorAcuse(fmt.Sprintf("el acuse al cliente %s no salió: %v", ticket.EmailFrom, err))
} else { } else {
log.Printf("[Soporte] Auto-respuesta enviada a %s (ticket #%d)", ticket.EmailFrom, ticket.ID) log.Printf("[Soporte] Auto-respuesta enviada a %s (ticket #%d)", ticket.EmailFrom, ticket.ID)
guardarErrorAcuse("")
} }
}() }()
} }
@@ -165,3 +201,25 @@ func SendSoporteNotifAdmin(ticket *models.ProyectoTicket) {
} }
}() }()
} }
// El acuse se manda en segundo plano, así que su error no puede devolverse al
// que creó el ticket. Se guarda acá para poder mostrarlo en la pantalla de
// configuración, que es donde alguien lo va a ver.
var (
muErrorAcuse sync.Mutex
ultimoErrAcuse string
)
func guardarErrorAcuse(msg string) {
muErrorAcuse.Lock()
defer muErrorAcuse.Unlock()
ultimoErrAcuse = msg
}
// UltimoErrorAcuse devuelve el último fallo al mandarle el acuse a un cliente
// ("" si el último salió bien).
func UltimoErrorAcuse() string {
muErrorAcuse.Lock()
defer muErrorAcuse.Unlock()
return ultimoErrAcuse
}
+61
View File
@@ -0,0 +1,61 @@
package services
import (
"strings"
"testing"
)
// Cuando se agotan las rondas de herramientas, el visitante recibía un mensaje
// genérico aunque el modelo ya le hubiera escrito una respuesta completa: el
// texto que viene junto con una llamada a herramienta se descartaba.
//
// Se prueba la lógica de selección, que es la que decide qué ve la persona.
func TestSePrefiereElTextoDelModeloAlMensajeGenerico(t *testing.T) {
const generico = "Un momento, por favor — dame un poco más de detalle sobre lo que necesitas."
elegir := func(final, parcial string) string {
if final != "" {
return final
}
if strings.TrimSpace(parcial) != "" {
return parcial
}
return generico
}
casos := []struct {
nombre string
final string
parcial string
esperado string
}{
{"respuesta normal", "Los 4 pilares son…", "", "Los 4 pilares son…"},
{"se agotaron las rondas pero el modelo había escrito", "", "Los 4 pilares son…", "Los 4 pilares son…"},
{"el modelo nunca escribió nada", "", "", generico},
{"texto parcial en blanco no cuenta", "", " \n ", generico},
}
for _, c := range casos {
if got := elegir(c.final, c.parcial); got != c.esperado {
t.Errorf("%s: eligió %q, esperaba %q", c.nombre, got, c.esperado)
}
}
}
// El resultado de una herramienta que falló se reconoce por su forma. Si esa
// detección se rompe, el evento de diagnóstico deja de registrarse y "se
// agotaron las rondas" vuelve a no decir dónde mirar.
func TestSeDetectaElErrorDeUnaHerramienta(t *testing.T) {
casos := map[string]bool{
`{"error": "consulta requerida"}`: true,
` {"error": "el servicio de embeddings no está configurado"}`: true,
`{"resultados": ["texto"]}`: false,
`{"resultados": [], "nota": "No se encontró información relacionada"}`: false,
}
for entrada, esError := range casos {
got := strings.HasPrefix(strings.TrimSpace(entrada), `{"error"`)
if got != esError {
t.Errorf("%q: detectado como error = %v, esperaba %v", entrada, got, esError)
}
}
}
+58 -5
View File
@@ -283,8 +283,27 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
_ = models.SaveUmindMensaje(agente.ID, sessionID, "user", userText) _ = models.SaveUmindMensaje(agente.ID, sessionID, "user", userText)
var finalResponse string var finalResponse string
for round := 0; round < 3; round++ { // Lo último que el modelo llegó a escribir, aunque haya venido acompañado
aiMsg, tokens, err := callAI(&ai, messages, tools) // de una llamada a herramienta.
var ultimoTextoParcial string
// Para el evento de diagnóstico: sin saber qué herramienta pidió en cada
// ronda, "se agotaron las rondas" no dice dónde mirar.
var herramientasPedidas []string
// La última vuelta se llama SIN herramientas. Un modelo que encadena
// búsquedas —busca una cosa, después otra— se comía las rondas pidiendo
// tools y nunca llegaba a redactar: el visitante recibía el mensaje
// genérico aunque el agente tuviera toda la información junta. Sin
// herramientas disponibles no le queda otra que responder con lo que ya
// juntó, que es exactamente lo que se quiere en el último turno.
const maxRondas = 4
for round := 0; round < maxRondas; round++ {
disponibles := tools
if round == maxRondas-1 {
disponibles = nil
}
aiMsg, tokens, err := callAI(&ai, messages, disponibles)
if err != nil { if err != nil {
log.Printf("[UMIND] Error llamando AI (agente %d) round %d: %v", agente.ID, round, err) log.Printf("[UMIND] Error llamando AI (agente %d) round %d: %v", agente.ID, round, err)
models.RegistrarEventoUmind(agente.ID, "error", "ai", fmt.Sprintf("Error contactando el AI (ronda %d)", round), err.Error()) models.RegistrarEventoUmind(agente.ID, "error", "ai", fmt.Sprintf("Error contactando el AI (ronda %d)", round), err.Error())
@@ -314,11 +333,28 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
break break
} }
// Un modelo puede mandar texto Y pedir herramientas en el mismo turno.
// Ese texto se descartaba: si después se agotaban las rondas, el
// visitante recibía el mensaje genérico aunque el agente ya le hubiera
// escrito una respuesta buena.
if s, ok := aiMsg.Content.(string); ok && strings.TrimSpace(s) != "" {
ultimoTextoParcial = s
}
messages = append(messages, *aiMsg) messages = append(messages, *aiMsg)
for _, tc := range aiMsg.ToolCalls { for _, tc := range aiMsg.ToolCalls {
var toolArgs map[string]interface{} var toolArgs map[string]interface{}
_ = json.Unmarshal([]byte(tc.Function.Arguments), &toolArgs) _ = json.Unmarshal([]byte(tc.Function.Arguments), &toolArgs)
toolResult := executeUmindTool(agente.ID, tc.Function.Name, toolArgs) toolResult := executeUmindTool(agente.ID, tc.Function.Name, toolArgs)
herramientasPedidas = append(herramientasPedidas, tc.Function.Name)
// Una herramienta que devuelve error y el modelo que la reintenta es
// la forma más común de agotar las rondas. Queda registrado para
// poder verlo en Auditoría en vez de deducirlo.
if strings.HasPrefix(strings.TrimSpace(toolResult), `{"error"`) {
models.RegistrarEventoUmind(agente.ID, "warn", "tool",
fmt.Sprintf("La herramienta %q devolvió error en la ronda %d", tc.Function.Name, round),
toolResult)
}
messages = append(messages, agentMessage{ messages = append(messages, agentMessage{
Role: "tool", Role: "tool",
ToolCallID: tc.ID, ToolCallID: tc.ID,
@@ -329,9 +365,26 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
} }
if finalResponse == "" { if finalResponse == "" {
log.Printf("[UMIND] Agente %d: se agotaron las rondas de tool-calling sin una respuesta final", agente.ID) log.Printf("[UMIND] Agente %d: se agotaron las rondas de tool-calling sin respuesta final (herramientas: %v)",
models.RegistrarEventoUmind(agente.ID, "warn", "ai", "Se agotaron las rondas de tool-calling sin una respuesta final", "") agente.ID, herramientasPedidas)
finalResponse = "Un momento, por favor — dame un poco más de detalle sobre lo que necesitas." models.RegistrarEventoUmind(agente.ID, "warn", "ai",
"Se agotaron las 3 rondas de herramientas sin una respuesta final",
fmt.Sprintf("Herramientas pedidas en orden: %s", strings.Join(herramientasPedidas, " → ")))
// Antes de dar el mensaje genérico, usar lo que el modelo ya escribió.
if strings.TrimSpace(ultimoTextoParcial) != "" {
finalResponse = ultimoTextoParcial
} else {
finalResponse = "Un momento, por favor — dame un poco más de detalle sobre lo que necesitas."
}
// Se guarda igual, aunque sea el mensaje genérico. Sin esto quedaba una
// conversación con varios mensajes del visitante y ninguna respuesta:
// no se veía en Conversaciones —el dueño no se enteraba de que su
// agente estaba fallando— y, peor, ese historial roto se le reenviaba
// al modelo en el turno siguiente. Un modelo que ve cuatro preguntas
// seguidas sin una sola respuesta se confunde y vuelve a fallar, así
// que el primer error se perpetuaba solo.
_ = models.SaveUmindMensaje(agente.ID, sessionID, "assistant", finalResponse)
} }
return finalResponse, nil return finalResponse, nil
} }
@@ -45,6 +45,12 @@ func ProcesarMensajeTelegramUmind(canal *models.UmindCanal, chatID int64, texto
// getFile, lo pasa por Whisper/OCR y responde igual que un mensaje de texto. // getFile, lo pasa por Whisper/OCR y responde igual que un mensaje de texto.
// Si no está habilitada, se ignora en silencio. // Si no está habilitada, se ignora en silencio.
func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID, tipo string) error { func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID, tipo string) error {
return ProcesarMediaTelegramUmindConNombre(canal, chatID, fileID, tipo, "", "")
}
// ProcesarMediaTelegramUmindConNombre es la versión completa: los documentos
// traen nombre de archivo y, a veces, un texto que los acompaña (caption).
func ProcesarMediaTelegramUmindConNombre(canal *models.UmindCanal, chatID int64, fileID, tipo, nombreArchivo, caption string) error {
agente, err := models.GetUmindAgenteByID(canal.AgenteID) agente, err := models.GetUmindAgenteByID(canal.AgenteID)
if err != nil || !agente.Activo { if err != nil || !agente.Activo {
return fmt.Errorf("agente no encontrado o inactivo: %w", err) return fmt.Errorf("agente no encontrado o inactivo: %w", err)
@@ -84,6 +90,19 @@ func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID,
if err != nil { if err != nil {
return err return err
} }
case "document":
if !canal.UsarArchivosDocs {
return nil
}
data, err := descargarArchivoTelegram(botToken, fileID)
if err != nil {
return fmt.Errorf("no se pudo descargar el archivo de Telegram: %w", err)
}
texto, err = ExtraerTextoDeArchivo(canal.AgenteID, nombreArchivo, data)
if err != nil {
return err
}
texto = TextoDeArchivoParaAgente(nombreArchivo, caption, texto)
default: default:
return nil return nil
} }
@@ -58,6 +58,12 @@ func ProcesarMensajeWhatsAppUmind(canal *models.UmindCanal, from, texto string)
// Si la conversión no está habilitada para ese tipo, se ignora en silencio // Si la conversión no está habilitada para ese tipo, se ignora en silencio
// (mismo comportamiento de antes de que existiera esta función). // (mismo comportamiento de antes de que existiera esta función).
func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo string) error { func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo string) error {
return ProcesarMediaWhatsAppUmindConNombre(canal, from, mediaID, tipo, "", "")
}
// ProcesarMediaWhatsAppUmindConNombre es la versión completa: los documentos
// traen nombre de archivo y, a veces, un texto que los acompaña (caption).
func ProcesarMediaWhatsAppUmindConNombre(canal *models.UmindCanal, from, mediaID, tipo, nombreArchivo, caption string) error {
agente, err := models.GetUmindAgenteByID(canal.AgenteID) agente, err := models.GetUmindAgenteByID(canal.AgenteID)
if err != nil || !agente.Activo { if err != nil || !agente.Activo {
return fmt.Errorf("agente no encontrado o inactivo: %w", err) return fmt.Errorf("agente no encontrado o inactivo: %w", err)
@@ -96,6 +102,19 @@ func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo st
if err != nil { if err != nil {
return err return err
} }
case "document":
if !canal.UsarArchivosDocs {
return nil
}
data, _, err := descargarMediaWhatsApp(credenciales["access_token"], mediaID)
if err != nil {
return fmt.Errorf("no se pudo descargar el archivo de WhatsApp: %w", err)
}
texto, err = ExtraerTextoDeArchivo(canal.AgenteID, nombreArchivo, data)
if err != nil {
return err
}
texto = TextoDeArchivoParaAgente(nombreArchivo, caption, texto)
default: default:
return nil return nil
} }
+126
View File
@@ -0,0 +1,126 @@
package services
import (
"fmt"
"log"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// DuplicarAgente crea una copia de un agente en el tenant indicado, con su
// conocimiento y sus herramientas.
//
// Es lo que convierte a un agente bien configurado en la plantilla de los que
// vengan después: el catálogo de rubros da un punto de partida genérico, pero
// el mejor punto de partida para el segundo restaurante es el primero.
//
// Lo que NO se copia, y por qué:
// - Los canales (WhatsApp, Telegram): llevan credenciales de una cuenta
// concreta. Copiarlas haría que dos agentes contesten por el mismo número.
// - Las conversaciones: son de los clientes del otro negocio.
// - El valor de los headers de auth de las herramientas: es un secreto de un
// tercero, atado a una cuenta. La herramienta se copia sin él, para que
// quien reciba la copia lo cargue.
func DuplicarAgente(origenID, tenantDestino uint, nombreNuevo string) (*models.UmindAgente, error) {
origen, err := models.GetUmindAgenteByID(origenID)
if err != nil {
return nil, fmt.Errorf("el agente que querés copiar no existe: %w", err)
}
if tenantDestino == 0 {
tenantDestino = origen.TenantID
}
if nombreNuevo == "" {
nombreNuevo = origen.Nombre + " (copia)"
}
copia := &models.UmindAgente{
TenantID: tenantDestino,
Nombre: nombreNuevo,
AiConfigID: origen.AiConfigID,
Tono: origen.Tono,
MensajeBienvenida: origen.MensajeBienvenida,
Color: origen.Color,
Activo: true,
// SiteKey se genera sola en CreateUmindAgente: es la llave pública del
// widget y tiene índice único, compartirla sería servir dos agentes
// distintos bajo la misma identidad.
}
if err := models.CreateUmindAgente(copia); err != nil {
return nil, fmt.Errorf("no se pudo crear la copia: %w", err)
}
go copiarConocimiento(origen.ID, copia.ID)
copiarHerramientas(origen.ID, copia.ID)
return copia, nil
}
// copiarConocimiento rehace las fuentes en el agente nuevo. Se vuelven a
// generar los embeddings en vez de copiar los vectores: es más lento, pero los
// chunks viejos pueden venir de un modelo de embeddings distinto al actual, y
// mezclarlos rompe la comparación por similitud.
func copiarConocimiento(origenID, destinoID uint) {
docs, err := models.GetUmindDocumentosByAgente(origenID)
if err != nil {
log.Printf("[UMIND] No se pudo leer el conocimiento del agente %d: %v", origenID, err)
return
}
copiados := 0
for _, d := range docs {
nuevo := &models.UmindDocumento{
AgenteID: destinoID,
Tipo: d.Tipo,
Origen: d.Origen,
Contenido: d.Contenido,
MaxPaginas: d.MaxPaginas,
AutoActualizar: d.AutoActualizar,
Estado: "procesando",
}
if err := models.CreateUmindDocumento(nuevo); err != nil {
log.Printf("[UMIND] No se pudo copiar la fuente %q: %v", d.Origen, err)
continue
}
switch {
case d.Tipo == "url":
IngestarAgente(destinoID, nuevo.ID, d.Origen, d.MaxPaginas)
case d.Contenido != "":
IngestarTexto(destinoID, nuevo.ID, d.Contenido)
default:
// Fuentes cargadas antes de que se guardara su texto: no hay de
// dónde rehacerlas sin el archivo original.
_ = models.UpdateUmindDocumentoEstado(nuevo.ID, "error",
"esta fuente se copió de un agente donde no quedó guardado su texto: volvé a subirla", 0)
continue
}
copiados++
}
log.Printf("[UMIND] Agente %d copiado desde %d: %d de %d fuentes", destinoID, origenID, copiados, len(docs))
}
// copiarHerramientas replica las tools sin su secreto de autenticación.
func copiarHerramientas(origenID, destinoID uint) {
tools, err := models.GetUmindHerramientasByAgente(origenID)
if err != nil {
log.Printf("[UMIND] No se pudieron leer las herramientas del agente %d: %v", origenID, err)
return
}
for _, t := range tools {
nueva := &models.UmindHerramienta{
AgenteID: destinoID,
Nombre: t.Nombre,
Descripcion: t.Descripcion,
ParametrosJSON: t.ParametrosJSON,
URL: t.URL,
AuthHeaderNombre: t.AuthHeaderNombre,
// AuthHeaderValorEnc queda vacío a propósito: es la credencial de
// una cuenta concreta. La copia arranca desactivada si la
// necesitaba, para que nadie descubra que faltaba en producción.
Activa: t.Activa && (t.AuthHeaderNombre == "" || t.AuthHeaderValorEnc == ""),
}
if err := models.CreateUmindHerramienta(nueva); err != nil {
log.Printf("[UMIND] No se pudo copiar la herramienta %q: %v", t.Nombre, err)
}
}
}
@@ -0,0 +1,56 @@
package services
import (
"strings"
"testing"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// Copiar una herramienta con su header de auth le daría al dueño del agente
// nuevo la credencial de un tercero que no es suya. La copia tiene que llegar
// sin el secreto — y desactivada, para que la falta se note al configurarla y
// no cuando un cliente recibe un error.
func TestLaCopiaDeHerramientaNoArrastraElSecreto(t *testing.T) {
casos := []struct {
nombre string
headerNombre string
headerValor string
activaOrigen bool
activaEsperada bool
}{
{"con auth: llega desactivada", "Authorization", "cifrado-xyz", true, false},
{"sin auth: conserva su estado", "", "", true, true},
{"sin auth y desactivada: sigue desactivada", "", "", false, false},
}
for _, c := range casos {
origen := models.UmindHerramienta{
Nombre: "consultar_stock",
AuthHeaderNombre: c.headerNombre,
AuthHeaderValorEnc: c.headerValor,
Activa: c.activaOrigen,
}
// Misma expresión que usa copiarHerramientas.
activa := origen.Activa && (origen.AuthHeaderNombre == "" || origen.AuthHeaderValorEnc == "")
if activa != c.activaEsperada {
t.Errorf("%s: activa = %v, esperaba %v", c.nombre, activa, c.activaEsperada)
}
}
}
// El nombre por defecto tiene que distinguir la copia del original: dos
// agentes con el mismo nombre en la misma lista no se pueden diferenciar.
func TestNombrePorDefectoDeLaCopia(t *testing.T) {
nombreNuevo := ""
original := "Ventas"
if nombreNuevo == "" {
nombreNuevo = original + " (copia)"
}
if nombreNuevo == original {
t.Error("la copia no puede llamarse igual que el original")
}
if !strings.Contains(nombreNuevo, original) {
t.Errorf("el nombre de la copia debería reconocerse: %q", nombreNuevo)
}
}
+42
View File
@@ -0,0 +1,42 @@
package services
import (
"os"
"strings"
"testing"
)
// Todo lo que ve el visitante tiene que quedar guardado, incluido el mensaje
// genérico de cuando algo falla. Sin eso pasaban dos cosas, y la segunda es la
// grave:
//
// 1. En Conversaciones se veían las preguntas sin ninguna respuesta, así que
// el dueño no se enteraba de que su agente estaba fallando.
// 2. Ese historial roto se le reenviaba al modelo en el turno siguiente. Un
// modelo que ve cuatro preguntas seguidas sin una sola respuesta se
// confunde y vuelve a fallar: el primer error se perpetuaba solo.
func TestLaRespuestaAlVisitanteSiempreQuedaGuardada(t *testing.T) {
b, err := os.ReadFile("umind_agent_service.go")
if err != nil {
t.Fatal(err)
}
fuente := string(b)
// El bloque del fallback tiene que guardar.
i := strings.Index(fuente, `if finalResponse == "" {`)
if i < 0 {
t.Fatal("no encontré el bloque de fallback")
}
bloque := fuente[i:]
if j := strings.Index(bloque, "\n\treturn finalResponse"); j > 0 {
bloque = bloque[:j]
}
if !strings.Contains(bloque, "SaveUmindMensaje") {
t.Error("el mensaje genérico no se guarda: la conversación queda con preguntas sin respuesta y el historial roto se reenvía al modelo")
}
// Y el mensaje del visitante también, desde antes del bucle.
if !strings.Contains(fuente, `SaveUmindMensaje(agente.ID, sessionID, "user", userText)`) {
t.Error("no se guarda el mensaje del visitante")
}
}
+86 -17
View File
@@ -197,6 +197,13 @@ func trocearTexto(texto string) []string {
if siguiente <= inicio { if siguiente <= inicio {
siguiente = inicio + corte siguiente = inicio + corte
} }
// El solape caía en cualquier lado, así que el fragmento siguiente
// podía arrancar a mitad de una palabra ("alabra…"). Se corre hasta el
// espacio siguiente: media palabra suelta al principio no aporta nada
// al embedding y ensucia el fragmento que se le muestra al modelo.
if esp := strings.IndexByte(texto[siguiente:], ' '); esp > 0 && siguiente+esp < inicio+corte {
siguiente += esp + 1
}
inicio = siguiente inicio = siguiente
} }
return chunks return chunks
@@ -212,16 +219,6 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("agente no encontrado: %v", err), 0) models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("agente no encontrado: %v", err), 0)
return return
} }
// Los embeddings usan una config global (módulo "umind_embeddings"), no la
// del agente: todos los chunks de todos los agentes deben salir del mismo
// modelo de embeddings para que la similitud coseno entre vectores tenga
// sentido. La config del agente (AiConfigID) es solo para el chat.
ai, err := models.GetUmindEmbeddingsConfig()
if err != nil {
models.UpdateUmindDocumentoEstado(documentoID, "error", err.Error(), 0)
return
}
if maxPaginas <= 0 { if maxPaginas <= 0 {
maxPaginas = 30 maxPaginas = 30
} }
@@ -235,10 +232,43 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
return return
} }
// Trocear todo el contenido crawleado en chunks de texto plano.
var textos []string var textos []string
for _, p := range paginas { for _, p := range paginas {
for _, c := range trocearTexto(p.Texto) { textos = append(textos, p.Texto)
}
if n, err := guardarConocimiento(agenteID, documentoID, textos); err != nil {
log.Printf("[UMIND] Ingesta de agente %d fallida: %v", agenteID, err)
} else {
log.Printf("[UMIND] Ingesta de agente %d completada: %d páginas, %d chunks", agenteID, len(paginas), n)
}
}
// IngestarTexto guarda como conocimiento un texto que se cargó a mano o que se
// extrajo de un archivo. Es el mismo trabajo que hace la ingesta de una URL
// desde que tiene el texto: trocear, embeber y guardar.
func IngestarTexto(agenteID, documentoID uint, texto string) {
if _, err := guardarConocimiento(agenteID, documentoID, []string{texto}); err != nil {
log.Printf("[UMIND] Ingesta de texto del agente %d fallida: %v", agenteID, err)
}
}
// guardarConocimiento trocea, genera los embeddings y reemplaza los fragmentos
// del documento. Reemplaza y no agrega: si no, reprocesar una fuente dejaría
// dos versiones del mismo contenido compitiendo en la búsqueda, y la vieja
// puede ganar.
func guardarConocimiento(agenteID, documentoID uint, fuentes []string) (int, error) {
// Los embeddings usan una config global (módulo "umind_embeddings"), no la
// del agente: todos los chunks de todos los agentes deben salir del mismo
// modelo para que la similitud coseno entre vectores tenga sentido.
ai, err := models.GetUmindEmbeddingsConfig()
if err != nil {
models.UpdateUmindDocumentoEstado(documentoID, "error", err.Error(), 0)
return 0, err
}
var textos []string
for _, fuente := range fuentes {
for _, c := range trocearTexto(fuente) {
if len(strings.TrimSpace(c)) < 40 { if len(strings.TrimSpace(c)) < 40 {
continue // fragmentos demasiado cortos no aportan al RAG continue // fragmentos demasiado cortos no aportan al RAG
} }
@@ -246,8 +276,9 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
} }
} }
if len(textos) == 0 { if len(textos) == 0 {
models.UpdateUmindDocumentoEstado(documentoID, "error", "no se generó ningún fragmento de texto aprovechable", 0) msg := "no se generó ningún fragmento de texto aprovechable"
return models.UpdateUmindDocumentoEstado(documentoID, "error", msg, 0)
return 0, fmt.Errorf("%s", msg)
} }
// Generar embeddings en tandas para no mandar un solo request gigante. // Generar embeddings en tandas para no mandar un solo request gigante.
@@ -262,7 +293,7 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
vectores, err := GenerarEmbeddings(ai, tanda) vectores, err := GenerarEmbeddings(ai, tanda)
if err != nil { if err != nil {
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error generando embeddings: %v", err), len(chunks)) models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error generando embeddings: %v", err), len(chunks))
return return 0, err
} }
for j, texto := range tanda { for j, texto := range tanda {
embJSON, err := models.EmbeddingToJSON(vectores[j]) embJSON, err := models.EmbeddingToJSON(vectores[j])
@@ -278,11 +309,49 @@ func IngestarAgente(agenteID uint, documentoID uint, urlInicial string, maxPagin
} }
} }
if err := models.BorrarChunksDeDocumento(documentoID); err != nil {
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("no se pudieron limpiar los fragmentos anteriores: %v", err), 0)
return 0, err
}
if err := models.CreateUmindChunks(chunks); err != nil { if err := models.CreateUmindChunks(chunks); err != nil {
models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error guardando fragmentos: %v", err), 0) models.UpdateUmindDocumentoEstado(documentoID, "error", fmt.Sprintf("error guardando fragmentos: %v", err), 0)
return return 0, err
} }
models.UpdateUmindDocumentoEstado(documentoID, "listo", "", len(chunks)) models.UpdateUmindDocumentoEstado(documentoID, "listo", "", len(chunks))
log.Printf("[UMIND] Ingesta de agente %d completada: %d páginas, %d chunks", agenteID, len(paginas), len(chunks)) return len(chunks), nil
}
// ReprocesarDocumento vuelve a procesar una fuente, en segundo plano.
// Una URL se recrawlea —es la única forma de que el agente deje de contestar
// con la información del año pasado— y una nota o un archivo se rearman desde
// el texto guardado, sin pedirle al dueño que lo vuelva a subir.
func ReprocesarDocumento(doc models.UmindDocumento) {
_ = models.UpdateUmindDocumentoEstado(doc.ID, "procesando", "", doc.TotalChunks)
if doc.Tipo == "url" {
go IngestarAgente(doc.AgenteID, doc.ID, doc.Origen, doc.MaxPaginas)
return
}
if strings.TrimSpace(doc.Contenido) == "" {
_ = models.UpdateUmindDocumentoEstado(doc.ID, "error",
"esta fuente se cargó antes de que se guardara su texto: volvé a subirla", 0)
return
}
go IngestarTexto(doc.AgenteID, doc.ID, doc.Contenido)
}
// RefrescarConocimientoUmind recrawlea las fuentes marcadas para actualizarse
// solas. Lo llama el cron: sin esto, el conocimiento se congela el día que se
// cargó y nadie se entera, porque no falla — solo queda viejo.
func RefrescarConocimientoUmind() {
const diasEntreRefrescos = 7
docs, err := models.GetDocumentosParaRefrescar(diasEntreRefrescos)
if err != nil {
log.Printf("[UMIND] No se pudieron buscar fuentes para refrescar: %v", err)
return
}
for _, d := range docs {
log.Printf("[UMIND] Refrescando fuente %d del agente %d (%s)", d.ID, d.AgenteID, d.Origen)
ReprocesarDocumento(d)
}
} }
+38
View File
@@ -0,0 +1,38 @@
package services
import (
"strings"
"testing"
)
// El troceado es lo que decide qué puede encontrar el agente después. Un
// fragmento cortado a la mitad de una frase o sin solape hace que la búsqueda
// devuelva texto que no responde nada.
func TestTrocearTexto(t *testing.T) {
if got := trocearTexto(" "); got != nil {
t.Errorf("texto vacío debería dar nil, dio %v", got)
}
corto := "Atendemos de 9 a 18."
if got := trocearTexto(corto); len(got) != 1 || got[0] != corto {
t.Errorf("un texto corto tiene que quedar en un solo fragmento: %v", got)
}
largo := strings.Repeat("palabra ", 900)
chunks := trocearTexto(largo)
if len(chunks) < 2 {
t.Fatalf("un texto largo debería partirse, dio %d fragmento(s)", len(chunks))
}
for i, c := range chunks {
if strings.HasPrefix(c, " ") || strings.HasSuffix(c, " ") {
t.Errorf("fragmento %d con espacios en los bordes: %q", i, c)
}
if c == "" {
t.Errorf("fragmento %d vacío", i)
}
}
// Sin solape, una frase que cae justo en el corte se pierde para siempre.
if !strings.HasPrefix(chunks[1], "palabra") {
t.Errorf("el segundo fragmento debería arrancar con contenido real: %q", chunks[1][:20])
}
}
+300
View File
@@ -0,0 +1,300 @@
package services
import (
"log"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// PlantillaRubro es un punto de partida para un agente nuevo: el conocimiento
// base que casi todos los negocios de ese rubro necesitan, ya escrito.
//
// No reemplaza la información real del cliente — la reemplaza el cliente
// editando estas notas. Existe porque un agente vacío no sirve para nada el
// primer día, y escribir desde cero frente a un campo en blanco es donde la
// mayoría abandona.
type PlantillaRubro struct {
Clave string `json:"clave"`
Nombre string `json:"nombre"`
Descripcion string `json:"descripcion"`
Tono string `json:"tono"`
Bienvenida string `json:"mensaje_bienvenida"`
Notas []NotaPlanura `json:"notas"`
}
// NotaPlanura es una nota de conocimiento con su texto de ejemplo. El texto
// está escrito para que se note que hay que cambiarlo: valores entre
// corchetes, nunca datos inventados que parezcan reales.
type NotaPlanura struct {
Titulo string `json:"titulo"`
Contenido string `json:"contenido"`
}
// PlantillasRubro es el catálogo. Vive en código y no en base de datos a
// propósito: son contenido editorial que cambia cuando lo mejoramos nosotros,
// no configuración que cada instalación toca por su lado.
var PlantillasRubro = []PlantillaRubro{
{
Clave: "generico",
Nombre: "Negocio general",
Descripcion: "La base que sirve para cualquier rubro: horarios, contacto, formas de pago.",
Tono: "amable y directo",
Bienvenida: "¡Hola! Contame en qué te puedo ayudar.",
Notas: []NotaPlanura{
{
Titulo: "Horarios y contacto",
Contenido: `Atendemos de [lunes a viernes] de [9:00] a [18:00], y los [sábados] de [9:00] a [13:00].
Los [domingos] y feriados no atendemos.
Nos pueden escribir por WhatsApp al [número] o al correo [correo].
Estamos en [dirección completa], [ciudad].`,
},
{
Titulo: "Formas de pago",
Contenido: `Aceptamos [efectivo, tarjeta débito y crédito, transferencia y Nequi].
[No] aceptamos [cheques].
Para transferencias, la cuenta es [banco, tipo de cuenta, número] a nombre de [titular].
El pago se confirma cuando [nos llega el comprobante / se acredita].`,
},
{
Titulo: "Qué NO hacemos",
Contenido: `Escribí acá lo que la gente pregunta seguido y la respuesta es que no.
Ejemplos: no hacemos [envíos fuera de la ciudad], no trabajamos con [cierta marca],
no atendemos [sin cita previa].
Esto evita que el asistente prometa cosas que después no podés cumplir.`,
},
},
},
{
Clave: "tienda",
Nombre: "Tienda / e-commerce",
Descripcion: "Envíos, cambios, garantía y estado de pedidos.",
Tono: "cercano y resolutivo",
Bienvenida: "¡Hola! ¿Buscás algo en particular o querés saber por un pedido?",
Notas: []NotaPlanura{
{
Titulo: "Envíos y zonas de cobertura",
Contenido: `Hacemos envíos a [ciudades o zonas].
El costo del envío es [$X] y demora [N] días hábiles.
Envío gratis por compras superiores a [$X].
A [zonas donde no llegan] no hacemos envíos.
El pedido se despacha [el mismo día si se hace antes de las X / al día siguiente].`,
},
{
Titulo: "Cambios y devoluciones",
Contenido: `Se pueden cambiar productos dentro de los [N] días desde la entrega.
El producto tiene que estar [sin uso, con etiqueta, en su empaque original].
[No] aceptamos cambios en [productos en promoción / ropa interior / etc.].
El costo del envío del cambio lo cubre [el cliente / nosotros].`,
},
{
Titulo: "Garantía",
Contenido: `Los productos tienen [N meses] de garantía por defectos de fábrica.
La garantía no cubre [mal uso, daño por golpes, desgaste normal].
Para hacerla efectiva hace falta [la factura / el número de pedido].`,
},
{
Titulo: "Estado de un pedido",
Contenido: `Si preguntan por un pedido, pediles el [número de pedido] y decíles que
alguien del equipo lo revisa y responde en [tiempo].
No inventes estados de pedido: el asistente no tiene acceso al sistema de envíos.`,
},
},
},
{
Clave: "restaurante",
Nombre: "Restaurante / comida",
Descripcion: "Menú, reservas, domicilios y restricciones alimentarias.",
Tono: "cálido y breve",
Bienvenida: "¡Hola! ¿Querés ver el menú, reservar o pedir a domicilio?",
Notas: []NotaPlanura{
{
Titulo: "Horarios de cocina y domicilios",
Contenido: `La cocina atiende de [12:00] a [15:00] y de [19:00] a [23:00].
Los domicilios salen hasta las [22:30].
[Día] cerramos.
El domicilio demora entre [N] y [M] minutos según la zona.`,
},
{
Titulo: "Zonas de domicilio y costo",
Contenido: `Llevamos a [barrios o zonas].
El costo del domicilio es [$X] o gratis por pedidos mayores a [$X].
A [zonas] no llegamos.
Pedido mínimo: [$X].`,
},
{
Titulo: "Reservas",
Contenido: `Tomamos reservas para grupos de [N] personas o más.
Se reserva con [N horas] de anticipación.
[Sí/No] cobramos seña.
Las mesas se guardan [N] minutos después de la hora reservada.`,
},
{
Titulo: "Restricciones y alergias",
Contenido: `Tenemos opciones [vegetarianas / veganas / sin gluten / sin lactosa]: [cuáles].
Nuestra cocina [sí/no] es libre de [maní, mariscos, gluten] — importante para alergias.
Ante una consulta por alergia grave, siempre derivar a una persona del equipo.`,
},
},
},
{
Clave: "salud",
Nombre: "Consultorio / clínica",
Descripcion: "Turnos, obras sociales y qué NO puede responder un asistente.",
Tono: "profesional y claro",
Bienvenida: "Hola, ¿querés sacar un turno o consultar por coberturas?",
Notas: []NotaPlanura{
{
Titulo: "Límite importante: no damos consejo médico",
Contenido: `Este asistente NO responde consultas médicas, no interpreta síntomas,
no sugiere tratamientos ni medicamentos, y no dice si algo es urgente.
Ante cualquier consulta de salud, la respuesta es derivar a un profesional
y ofrecer sacar un turno. Si la persona describe una urgencia, indicar que
llame al [número de emergencias] o vaya a la guardia más cercana.`,
},
{
Titulo: "Turnos",
Contenido: `Atendemos con turno previo de [lunes a viernes] de [horario].
Los turnos se sacan por [WhatsApp / teléfono / la web].
La consulta dura aproximadamente [N] minutos.
Si no podés venir, avisanos con [N horas] de anticipación.`,
},
{
Titulo: "Obras sociales y particulares",
Contenido: `Trabajamos con [lista de obras sociales / prepagas].
[No] atendemos por [obras sociales específicas].
La consulta particular cuesta [$X].
Traer [documento, credencial, orden] el día del turno.`,
},
{
Titulo: "Dirección y cómo llegar",
Contenido: `Estamos en [dirección], [ciudad].
[Referencias para llegar].
[Hay / no hay] estacionamiento.
El consultorio [es / no es] accesible para silla de ruedas.`,
},
},
},
{
Clave: "servicios",
Nombre: "Servicios profesionales",
Descripcion: "Para estudios, agencias, técnicos: qué hacen, cómo cotizan, plazos.",
Tono: "profesional y concreto",
Bienvenida: "Hola, contame qué necesitás y te oriento.",
Notas: []NotaPlanura{
{
Titulo: "Qué hacemos y qué no",
Contenido: `Nos dedicamos a [descripción del servicio].
Trabajamos con [tipo de clientes / rubros].
No hacemos [servicios fuera del alcance] — para eso conviene [alternativa].`,
},
{
Titulo: "Cómo cotizamos",
Contenido: `Cada trabajo se cotiza según [alcance, horas, complejidad].
Un [servicio típico] arranca en [$X].
La cotización es [gratuita] y demora [N] días.
Para cotizar necesitamos saber [qué datos].
Nunca des un precio cerrado si no está en esta lista: ofrecé armar una cotización.`,
},
{
Titulo: "Plazos y forma de trabajo",
Contenido: `Un proyecto [típico] lleva entre [N] y [M] [semanas].
Trabajamos con [N]% de anticipo y el resto [contra entrega / en cuotas].
Las entregas se coordinan por [canal].`,
},
},
},
{
Clave: "belleza",
Nombre: "Peluquería / estética",
Descripcion: "Servicios, precios, turnos y políticas de cancelación.",
Tono: "amable y cercano",
Bienvenida: "¡Hola! ¿Querés reservar un turno o consultar precios?",
Notas: []NotaPlanura{
{
Titulo: "Servicios y precios",
Contenido: `[Corte]: desde [$X] — dura [N] minutos.
[Color]: desde [$X] — dura [N] minutos.
[Otros servicios con precio y duración].
Los precios varían según [largo de pelo / cantidad de producto]; si preguntan
por un caso puntual, ofrecé una consulta previa sin cargo.`,
},
{
Titulo: "Turnos y cancelaciones",
Contenido: `Trabajamos con turno previo, de [lunes a sábado] de [horario].
Se reserva por [WhatsApp].
Si no podés venir, avisá con [N horas] de anticipación.
[Cobramos / no cobramos] seña para [servicios largos].
Llegar más de [N] minutos tarde puede significar reprogramar el turno.`,
},
{
Titulo: "Cuidados previos y posteriores",
Contenido: `Para [color / alisado], venir con el pelo [lavado / sin lavar].
Después de [servicio], no [lavarse el pelo / mojarse] por [N] horas.
Recomendamos [productos] para mantener el resultado.`,
},
},
},
}
// PlantillaRubroPorClave busca una plantilla del catálogo.
func PlantillaRubroPorClave(clave string) (*PlantillaRubro, bool) {
for i := range PlantillasRubro {
if PlantillasRubro[i].Clave == clave {
return &PlantillasRubro[i], true
}
}
return nil, false
}
// AplicarPlantillaRubro carga las notas de una plantilla como conocimiento del
// agente. Cada nota entra como una fuente de tipo "texto" editable: el dueño
// abre, reemplaza los corchetes por sus datos y guarda.
//
// Se ejecuta en segundo plano porque cada nota necesita sus embeddings, y el
// agente ya se creó — hacer esperar la respuesta del alta por esto sería
// castigar al que elige plantilla.
func AplicarPlantillaRubro(agenteID uint, clave string) {
plantilla, ok := PlantillaRubroPorClave(clave)
if !ok {
log.Printf("[UMIND] Plantilla de rubro %q no existe, el agente %d queda vacío", clave, agenteID)
return
}
for _, nota := range plantilla.Notas {
doc := &models.UmindDocumento{
AgenteID: agenteID,
Tipo: "texto",
Origen: nota.Titulo,
Contenido: nota.Contenido,
Estado: "procesando",
}
if err := models.CreateUmindDocumento(doc); err != nil {
log.Printf("[UMIND] No se pudo crear la nota %q del agente %d: %v", nota.Titulo, agenteID, err)
continue
}
IngestarTexto(agenteID, doc.ID, nota.Contenido)
}
log.Printf("[UMIND] Agente %d arrancó con la plantilla %q (%d notas)", agenteID, clave, len(plantilla.Notas))
}
// ResumenPlantillasRubro devuelve el catálogo sin el contenido completo de las
// notas — es lo que necesita la pantalla de elección para mostrar las opciones.
func ResumenPlantillasRubro() []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(PlantillasRubro))
for _, p := range PlantillasRubro {
titulos := make([]string, 0, len(p.Notas))
for _, n := range p.Notas {
titulos = append(titulos, n.Titulo)
}
out = append(out, map[string]interface{}{
"clave": p.Clave,
"nombre": p.Nombre,
"descripcion": p.Descripcion,
"notas": len(p.Notas),
"titulos": titulos,
"resumen": strings.Join(titulos, " · "),
})
}
return out
}
@@ -0,0 +1,89 @@
package services
import (
"strings"
"testing"
)
// El catálogo es contenido que se le muestra al cliente y que además define el
// conocimiento inicial de su agente. Un título vacío o una nota sin marcadores
// para completar significa que alguien va a publicar datos de ejemplo como si
// fueran reales.
func TestCatalogoDeRubrosEstaCompleto(t *testing.T) {
if len(PlantillasRubro) < 3 {
t.Fatalf("el catálogo tiene %d plantillas, muy pocas para ofrecer", len(PlantillasRubro))
}
vistas := map[string]bool{}
for _, p := range PlantillasRubro {
if p.Clave == "" || p.Nombre == "" || p.Descripcion == "" {
t.Errorf("plantilla incompleta: %+v", p.Clave)
}
if vistas[p.Clave] {
t.Errorf("clave duplicada: %q — la segunda nunca se podría elegir", p.Clave)
}
vistas[p.Clave] = true
if len(p.Notas) == 0 {
t.Errorf("la plantilla %q no trae ninguna nota: elegirla sería igual que no elegir nada", p.Clave)
}
for _, n := range p.Notas {
if strings.TrimSpace(n.Titulo) == "" {
t.Errorf("nota sin título en %q", p.Clave)
}
if len(strings.TrimSpace(n.Contenido)) < 40 {
t.Errorf("nota %q de %q demasiado corta: no generaría ni un fragmento", n.Titulo, p.Clave)
}
}
}
}
// Los valores de ejemplo van entre corchetes justamente para que se note que
// hay que reemplazarlos. Una nota sin corchetes se lee como dato real y termina
// en boca del asistente.
func TestLasNotasPidenSerCompletadas(t *testing.T) {
for _, p := range PlantillasRubro {
for _, n := range p.Notas {
if !strings.Contains(n.Contenido, "[") {
t.Errorf("la nota %q de %q no tiene ningún marcador [entre corchetes]: se puede confundir con información real", n.Titulo, p.Clave)
}
}
}
}
// El rubro de salud no puede ofrecerse sin decir explícitamente que el
// asistente no da consejo médico: es el único donde una respuesta inventada
// hace daño de verdad.
func TestSaludAclaraQueNoDaConsejoMedico(t *testing.T) {
p, ok := PlantillaRubroPorClave("salud")
if !ok {
t.Fatal("falta la plantilla de salud")
}
junto := strings.ToLower(p.Notas[0].Titulo + " " + p.Notas[0].Contenido)
if !strings.Contains(junto, "no responde consultas médicas") && !strings.Contains(junto, "no damos consejo") {
t.Error("la primera nota de salud tiene que ser el límite: el asistente no da consejo médico")
}
}
func TestPlantillaRubroPorClave(t *testing.T) {
if _, ok := PlantillaRubroPorClave("no-existe"); ok {
t.Error("una clave inventada no debería resolver")
}
p, ok := PlantillaRubroPorClave("restaurante")
if !ok || p.Nombre == "" {
t.Error("restaurante debería existir en el catálogo")
}
}
func TestResumenNoFiltraElContenido(t *testing.T) {
// El resumen alimenta la pantalla de elección: manda títulos, no el texto
// completo de cada nota.
for _, r := range ResumenPlantillasRubro() {
if _, hay := r["contenido"]; hay {
t.Error("el resumen no debería incluir el contenido de las notas")
}
if r["clave"] == "" || r["nombre"] == "" {
t.Errorf("resumen incompleto: %+v", r)
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package services
import "testing"
// El bucle de herramientas tiene que garantizar que la última vuelta sea sin
// herramientas. Si no, un modelo que encadena búsquedas se come todas las
// rondas pidiendo tools y nunca redacta: el visitante recibe el mensaje
// genérico aunque el agente ya tenga toda la información junta.
//
// Es exactamente lo que pasaba en producción: tres eventos seguidos de
// "se agotaron las rondas" en un agente que respondía bien en el chat de
// prueba, donde la conversación era más corta y no encadenaba búsquedas.
func TestLaUltimaRondaVaSinHerramientas(t *testing.T) {
const maxRondas = 4
hubo := 0
ultimaConHerramientas := false
for round := 0; round < maxRondas; round++ {
conHerramientas := round != maxRondas-1
hubo++
if round == maxRondas-1 {
ultimaConHerramientas = conHerramientas
}
}
if hubo != maxRondas {
t.Errorf("se corrieron %d rondas, esperaba %d", hubo, maxRondas)
}
if ultimaConHerramientas {
t.Error("la última ronda ofreció herramientas: el modelo puede volver a pedir una y quedarse sin responder")
}
if maxRondas < 3 {
t.Errorf("con %d rondas no alcanza para buscar y después redactar", maxRondas)
}
}
// Con la última ronda sin herramientas, un modelo que pide búsquedas en todas
// las vueltas igual termina respondiendo.
func TestUnModeloQuePideBusquedasSiempreIgualResponde(t *testing.T) {
const maxRondas = 4
respondio := false
for round := 0; round < maxRondas; round++ {
hayHerramientas := round != maxRondas-1
// El modelo simulado pide herramienta siempre que pueda.
pideHerramienta := hayHerramientas
if !pideHerramienta {
respondio = true
break
}
}
if !respondio {
t.Error("el bucle terminó sin respuesta: es el fallback genérico que ve el visitante")
}
}
+38 -21
View File
@@ -3,24 +3,11 @@ package services
import ( import (
"fmt" "fmt"
"log" "log"
"sync"
"time" "time"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
) )
// avisosTope recuerda a qué tenants ya se les avisó en el ciclo actual, para
// no mandar un aviso por cada mensaje una vez pasado el tope. Se pierde al
// reiniciar (y entonces se vuelve a avisar una vez): aceptable para un aviso,
// y mucho más simple que una columna de estado en el tenant.
//
// ponytail: en memoria y por proceso. Si algún día corren varias réplicas,
// cada una avisa una vez — mover el flag a la BD si eso molesta.
var avisosTope struct {
sync.Mutex
ultimo map[uint]string // tenantID → "2026-08" del último aviso
}
// RegistrarUso persiste el consumo y avisa si el tenant superó el tope de su // RegistrarUso persiste el consumo y avisa si el tenant superó el tope de su
// plan. El aviso NO corta el servicio: el agente sigue respondiendo. // plan. El aviso NO corta el servicio: el agente sigue respondiendo.
func RegistrarUso(agenteID uint, tipo string, cantidad float64, unidad string) { func RegistrarUso(agenteID uint, tipo string, cantidad float64, unidad string) {
@@ -65,14 +52,9 @@ func cicloActual() (time.Time, time.Time) {
func avisarTopeConsumo(tenantID uint, plan *models.UmindPlan, total float64, desde time.Time) { func avisarTopeConsumo(tenantID uint, plan *models.UmindPlan, total float64, desde time.Time) {
periodo := desde.Format("2006-01") periodo := desde.Format("2006-01")
avisosTope.Lock() // El marcado va en la base y de forma atómica: dos mensajes que crucen el
if avisosTope.ultimo == nil { // tope al mismo tiempo (o dos réplicas del proceso) avisan una sola vez.
avisosTope.ultimo = map[uint]string{} if !models.MarcarAvisoTope(tenantID, periodo) {
}
yaAvisado := avisosTope.ultimo[tenantID] == periodo
avisosTope.ultimo[tenantID] = periodo
avisosTope.Unlock()
if yaAvisado {
return return
} }
@@ -89,4 +71,39 @@ func avisarTopeConsumo(tenantID uint, plan *models.UmindPlan, total float64, des
"📊 <b>Tope de consumo superado</b>\nCliente: <b>%s</b>\nPlan: %s\nConsumo del mes: <b>%s %.2f</b> (tope %s %.2f)\n\n<i>El servicio sigue activo — el excedente se cobra en el próximo ciclo.</i>", "📊 <b>Tope de consumo superado</b>\nCliente: <b>%s</b>\nPlan: %s\nConsumo del mes: <b>%s %.2f</b> (tope %s %.2f)\n\n<i>El servicio sigue activo — el excedente se cobra en el próximo ciclo.</i>",
escapeTelegramHTML(nombre), escapeTelegramHTML(plan.Nombre), escapeTelegramHTML(nombre), escapeTelegramHTML(plan.Nombre),
plan.Moneda, total, plan.Moneda, plan.TopeConsumoMensual)) plan.Moneda, total, plan.Moneda, plan.TopeConsumoMensual))
avisarTopeAlCliente(tenantID, plan, total)
}
// avisarTopeAlCliente le manda el aviso al dueño del espacio. Enterarse recién
// en la factura de que se pasó del tope es la clase de sorpresa que hace
// perder un cliente, y hasta ahora sólo se avisaba al staff.
func avisarTopeAlCliente(tenantID uint, plan *models.UmindPlan, total float64) {
tenant, err := models.GetUmindTenantByID(tenantID)
if err != nil || tenant.ClienteID == nil {
return
}
usuarios, err := models.GetPortalUsersByClienteID(*tenant.ClienteID)
if err != nil || len(usuarios) == 0 {
return
}
asunto := "Tu asistente superó el consumo incluido en el plan"
cuerpo := fmt.Sprintf(`<p>Hola,</p>
<p>Tu asistente <strong>%s</strong> superó el consumo incluido en el plan %s este mes.</p>
<p>Consumo hasta ahora: <strong>%s %.2f</strong> — incluido en el plan: %s %.2f.</p>
<p><strong>El asistente sigue funcionando con normalidad.</strong> El excedente se factura en el próximo ciclo. Podés ver el detalle en tu panel, en Consumo.</p>`,
tenant.Nombre, plan.Nombre, plan.Moneda, total, plan.Moneda, plan.TopeConsumoMensual)
for _, u := range usuarios {
if u.Email == "" {
continue
}
destino := u.Email
go func() {
if err := soporteSendMail(destino, asunto, cuerpo); err != nil {
log.Printf("[UMIND_USO] No se pudo avisar del tope a %s: %v", destino, err)
}
}()
}
} }
+59
View File
@@ -0,0 +1,59 @@
package services
import (
"os"
"strings"
"testing"
"time"
)
// El período del aviso sale del mes calendario. Si se calculara mal, el aviso
// se repetiría todos los meses el día 1 o no se repetiría nunca más.
func TestElPeriodoDelCicloEsElMesCalendario(t *testing.T) {
desde, hasta := cicloActual()
if desde.Day() != 1 || desde.Hour() != 0 || desde.Minute() != 0 {
t.Errorf("el ciclo tiene que arrancar el día 1 a las 00:00, arrancó %v", desde)
}
if !hasta.After(desde) {
t.Errorf("el fin del ciclo (%v) tiene que ser posterior al inicio (%v)", hasta, desde)
}
if hasta.Sub(desde) < 28*24*time.Hour || hasta.Sub(desde) > 31*24*time.Hour {
t.Errorf("el ciclo dura %v, fuera del rango de un mes", hasta.Sub(desde))
}
// El período que se guarda para no repetir el aviso.
periodo := desde.Format("2006-01")
if len(periodo) != 7 || !strings.Contains(periodo, "-") {
t.Errorf("el período tiene que ser YYYY-MM, es %q", periodo)
}
// Tiene que entrar en la columna, que es size:7.
if len(periodo) > 7 {
t.Errorf("el período %q no entra en la columna de 7 caracteres", periodo)
}
}
// El aviso se manda una sola vez por mes. Antes el control estaba en memoria y
// cada despliegue lo borraba — con varios despliegues por día, el mismo cliente
// recibía el aviso una y otra vez.
func TestElControlDelAvisoNoViveEnMemoria(t *testing.T) {
fuente, err := leerFuente("umind_uso_service.go")
if err != nil {
t.Fatal(err)
}
if strings.Contains(fuente, "avisosTope") {
t.Error("quedó el flag en memoria: un reinicio vuelve a disparar el aviso")
}
if !strings.Contains(fuente, "MarcarAvisoTope") {
t.Error("el aviso tiene que marcarse en la base para sobrevivir a un reinicio")
}
// Y el cliente tiene que enterarse, no sólo el staff.
if !strings.Contains(fuente, "avisarTopeAlCliente") {
t.Error("falta el aviso al cliente: enterarse en la factura es la peor forma")
}
}
func leerFuente(nombre string) (string, error) {
b, err := os.ReadFile(nombre)
return string(b), err
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96" role="img" aria-label="uMind">
<title>uMind</title>
<rect width="96" height="96" rx="22" fill="#0D1220"/>
<path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#8eb02f" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M60,58 V64" fill="none" stroke="#8eb02f" stroke-width="10" stroke-linecap="round"/>
<circle cx="60" cy="28" r="7" fill="#8eb02f"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96" role="img" aria-label="uMind">
<title>uMind</title>
<rect width="96" height="96" rx="22" fill="#8eb02f"/>
<path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round"/>
<circle cx="60" cy="28" r="7" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 477 B

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
+3 -2
View File
@@ -3,9 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
<title>uMind Studio</title> <title>uMind Studio</title>
<script type="module" crossorigin src="/orchestrator/assets/index-CUSjvRBR.js"></script> <script type="module" crossorigin src="/orchestrator/assets/index-NXjEScsg.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DUjBJRtl.css"> <link rel="stylesheet" crossorigin href="/orchestrator/assets/index-CZn9dJyg.css">
</head> </head>
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens, <!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
que son los que cambian con el tema. Una utilidad acá le ganaba a la que son los que cambian con el tema. Una utilidad acá le ganaba a la
+1316
View File
File diff suppressed because it is too large Load Diff
+44 -7
View File
@@ -64,10 +64,17 @@
<tr class="hover:bg-gray-50 transition"> <tr class="hover:bg-gray-50 transition">
<td class="py-2 px-3 font-medium" x-text="item.nombre"></td> <td class="py-2 px-3 font-medium" x-text="item.nombre"></td>
<td class="py-2 px-3"> <td class="py-2 px-3">
<template x-if="!item.modulo"> <!-- Una config de cliente no tiene módulo, así que
antes se mostraba como "Global" — que es
justo lo que no es. -->
<template x-if="item.tenant_id">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-700"
x-text="'Cliente: ' + (item.tenant_nombre || ('espacio ' + item.tenant_id))"></span>
</template>
<template x-if="!item.modulo && !item.tenant_id">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-500">Global</span> <span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-500">Global</span>
</template> </template>
<template x-if="item.modulo"> <template x-if="item.modulo && !item.tenant_id">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<template x-for="m in item.modulo.split(',').filter(x => x.trim())" :key="m"> <template x-for="m in item.modulo.split(',').filter(x => x.trim())" :key="m">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold" <span class="px-2 py-0.5 rounded-full text-xs font-semibold"
@@ -200,6 +207,26 @@
<label for="ai_is_active" class="text-sm text-gray-700">Activo</label> <label for="ai_is_active" class="text-sm text-gray-700">Activo</label>
</div> </div>
<div class="border border-gray-200 rounded-lg p-3 bg-gray-50">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input x-model="form.es_agente_bot" type="checkbox" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
<span class="text-sm text-gray-700">Es el cerebro del agente (bot de Telegram y chat del panel)</span>
</label>
<p class="text-[11px] text-gray-400 mt-1 ml-6">
Solo una config puede serlo: al marcar esta, se desmarca la anterior.
</p>
<div x-show="form.es_agente_bot" x-cloak class="mt-2 ml-6">
<label class="block text-xs font-medium text-gray-600 mb-1">Bot de Telegram (opcional)</label>
<select x-model="form.telegram_config_id"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="">Sin bot asignado</option>
<template x-for="t in telegramConfigs" :key="t.ID">
<option :value="t.ID" x-text="t.nombre"></option>
</template>
</select>
</div>
</div>
<div> <div>
<label class="block text-xs font-medium text-gray-600 mb-2">Módulo / Servicio</label> <label class="block text-xs font-medium text-gray-600 mb-2">Módulo / Servicio</label>
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50"> <div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
@@ -288,17 +315,26 @@ function aiConfigApp() {
search: '', search: '',
showModal: false, editItem: null, deleteId: null, testResult: null, showModal: false, editItem: null, deleteId: null, testResult: null,
errorMsg: '', successMsg: '', formError: '', errorMsg: '', successMsg: '', formError: '',
form: { nombre: '', provider: '', api_key: '', base_url: '', model_name: '', is_active: true, notes: '', modulos: [] }, form: { nombre: '', provider: '', api_key: '', base_url: '', model_name: '', is_active: true, notes: '', modulos: [], es_agente_bot: false, telegram_config_id: '' },
telegramConfigs: [],
moduleOptions: [ moduleOptions: [
{ value: 'landing', label: 'Landing Generator' }, { value: 'landing', label: 'Landing Generator' },
{ value: 'query_runner', label: 'Query Runner SQL' }, { value: 'query_runner', label: 'Query Runner SQL' },
{ value: 'ia', label: 'IA / vCard' }, { value: 'ia', label: 'IA general (vCard, soporte, chat del panel)' },
{ value: 'plantillas', label: 'Plantillas de documento (importar con IA)' },
{ value: 'whisper', label: 'Transcripción de audio (Whisper)' }, { value: 'whisper', label: 'Transcripción de audio (Whisper)' },
{ value: 'umind_embeddings', label: 'uMind — embeddings (RAG del widget)' }, { value: 'umind_embeddings', label: 'uMind — embeddings (RAG del widget)' },
], ],
async init() { await this.load() }, async init() {
try {
const r = await fetch('/app/loadtelegram')
const d = await r.json()
this.telegramConfigs = d.registros || d.items || d || []
} catch { this.telegramConfigs = [] }
await this.load()
},
async load() { async load() {
this.loading = true this.loading = true
@@ -325,7 +361,7 @@ function aiConfigApp() {
openAdd() { openAdd() {
this.editItem = null this.editItem = null
this.form = { nombre: '', provider: 'qwen', api_key: '', base_url: '', model_name: 'qwen2.5-72b-instruct', is_active: true, notes: '', modulos: [] } this.form = { nombre: '', provider: 'qwen', api_key: '', base_url: '', model_name: 'qwen2.5-72b-instruct', is_active: true, notes: '', modulos: [], es_agente_bot: false, telegram_config_id: '' }
this.formError = '' this.formError = ''
this.showModal = true this.showModal = true
}, },
@@ -335,7 +371,7 @@ function aiConfigApp() {
const modulos = item.modulo const modulos = item.modulo
? item.modulo.split(',').map(s => s.trim()).filter(s => s) ? item.modulo.split(',').map(s => s.trim()).filter(s => s)
: [] : []
this.form = { nombre: item.nombre, provider: item.provider, api_key: '', base_url: item.base_url, model_name: item.model_name, is_active: item.is_active, notes: item.notes, modulos } this.form = { nombre: item.nombre, provider: item.provider, api_key: '', base_url: item.base_url, model_name: item.model_name, is_active: item.is_active, notes: item.notes, modulos, es_agente_bot: !!item.es_agente_bot, telegram_config_id: item.telegram_config_id || '' }
this.formError = '' this.formError = ''
this.showModal = true this.showModal = true
}, },
@@ -346,6 +382,7 @@ function aiConfigApp() {
this.saving = true; this.formError = '' this.saving = true; this.formError = ''
const payload = { ...this.form, modulo: this.form.modulos.join(',') } const payload = { ...this.form, modulo: this.form.modulos.join(',') }
delete payload.modulos delete payload.modulos
payload.telegram_config_id = payload.telegram_config_id ? parseInt(payload.telegram_config_id) : null
const url = this.editItem ? `/app/ai-config/${this.editItem.ID}` : '/app/ai-config' const url = this.editItem ? `/app/ai-config/${this.editItem.ID}` : '/app/ai-config'
const method = this.editItem ? 'PUT' : 'POST' const method = this.editItem ? 'PUT' : 'POST'
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
+11 -2
View File
@@ -105,7 +105,9 @@
<div> <div>
<label class="block text-xs font-medium text-gray-600 mb-2">Scopes *</label> <label class="block text-xs font-medium text-gray-600 mb-2">Scopes *</label>
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50"> <div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
<template x-for="s in ['oss', 'query_runner', 'usuarios', 'pasarelas', 'vcard']" :key="s"> <!-- La lista viene del backend: cuando estaba escrita acá, la
pantalla ofrecía scopes que el backend rechazaba al guardar. -->
<template x-for="s in scopesDisponibles" :key="s">
<label class="flex items-center gap-2 cursor-pointer select-none"> <label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" :checked="form.scopes.includes(s)" <input type="checkbox" :checked="form.scopes.includes(s)"
@change="toggleScope(s)" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]"> @change="toggleScope(s)" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
@@ -191,8 +193,15 @@ function apiKeysApp() {
showModal: false, editItem: null, deleteId: null, regenerarId: null, tokenResult: null, showModal: false, editItem: null, deleteId: null, regenerarId: null, tokenResult: null,
errorMsg: '', successMsg: '', formError: '', errorMsg: '', successMsg: '', formError: '',
form: { nombre: '', ip_permitida: '', scopes: [], activa: true }, form: { nombre: '', ip_permitida: '', scopes: [], activa: true },
scopesDisponibles: [],
async init() { await this.load() }, async init() {
try {
const r = await fetch('/app/api-keys/scopes')
this.scopesDisponibles = await r.json()
} catch { this.scopesDisponibles = [] }
await this.load()
},
async load() { async load() {
this.loading = true; this.errorMsg = '' this.loading = true; this.errorMsg = ''
@@ -76,7 +76,8 @@
<!-- Modal Crear / Editar --> <!-- Modal Crear / Editar -->
<div x-show="addModal || editModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div x-show="addModal || editModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl mx-4 p-6 max-h-[90vh] overflow-y-auto" @click.stop> <div class="bg-white rounded-lg shadow-xl w-full mx-4 p-6 max-h-[90vh] overflow-y-auto transition-all"
:class="mostrarPreview ? 'max-w-6xl' : 'max-w-4xl'" @click.stop>
<h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Plantilla' : 'Nueva Plantilla'"></h2> <h2 class="text-lg font-semibold mb-4" x-text="editModal ? 'Editar Plantilla' : 'Nueva Plantilla'"></h2>
<div class="mb-4 p-3 bg-blue-50 rounded text-xs text-blue-700 leading-6"> <div class="mb-4 p-3 bg-blue-50 rounded text-xs text-blue-700 leading-6">
@@ -90,6 +91,29 @@
<span class="block mt-1 text-blue-500">Cada tipo de documento pasa campos adicionales propios (ver /api/v2/spec).</span> <span class="block mt-1 text-blue-500">Cada tipo de documento pasa campos adicionales propios (ver /api/v2/spec).</span>
</div> </div>
<div class="mb-4 border border-dashed border-gray-300 rounded p-3 bg-gray-50">
<p class="text-xs font-medium text-gray-600 mb-1">¿Ya tenés el documento hecho?</p>
<p class="text-xs text-gray-500 mb-2">
Subí el archivo (PDF, Word .docx, .html, .txt o una foto/captura del documento) y la IA lo devuelve
armado como plantilla, con las variables puestas. Después lo editás acá abajo antes de guardar.
</p>
<p class="text-xs text-gray-400 mb-2">
No usa un agente: esto no conversa ni consulta una base de conocimiento, convierte el documento y listo.
El modelo sale de <a href="/app/ai-config" class="underline">Configuración de IA</a> — asignale el módulo
<em>Plantillas de documento</em> a la config que quieras usar acá, o dejalo sin asignar y usa la de <em>IA / vCard</em>.
</p>
<div class="flex flex-wrap items-center gap-2">
<input type="file" x-ref="archivo" accept=".pdf,.docx,.html,.htm,.txt,.md,image/*"
class="text-xs" />
<button type="button" @click="importar()" :disabled="importando"
class="px-3 py-1.5 border rounded text-xs disabled:opacity-50">
<span x-text="importando ? 'Leyendo…' : 'Leer con IA'"></span>
</button>
<span x-show="avisoImport" x-text="avisoImport" class="text-xs"
:class="errorImport ? 'text-red-600' : 'text-green-600'"></span>
</div>
</div>
<form @submit.prevent="save()"> <form @submit.prevent="save()">
<div class="grid grid-cols-3 gap-3 mb-3"> <div class="grid grid-cols-3 gap-3 mb-3">
<div> <div>
@@ -111,13 +135,36 @@
</label> </label>
</div> </div>
</div> </div>
<div> <div class="grid gap-3" :class="mostrarPreview ? 'lg:grid-cols-2' : ''">
<label class="text-xs font-medium text-gray-600 block mb-1">HTML de la plantilla (Go text/template) *</label> <div>
<textarea x-model="form.contenido_html" rows="16" <div class="flex items-center justify-between mb-1">
@input.debounce.600ms="validar()" <label class="text-xs font-medium text-gray-600">HTML de la plantilla (Go text/template) *</label>
:class="templateError ? 'border-red-400' : ''" <button type="button" @click="mostrarPreview = !mostrarPreview; if (mostrarPreview) previsualizar()"
class="w-full border rounded px-3 py-2 text-xs font-mono" required></textarea> class="text-xs px-2 py-1 border rounded">
<p x-show="templateError" x-text="templateError" class="text-red-500 text-xs mt-1"></p> <span x-text="mostrarPreview ? 'Ocultar vista previa' : '👁 Ver cómo queda'"></span>
</button>
</div>
<textarea x-model="form.contenido_html" rows="16"
@input.debounce.600ms="validar()"
:class="templateError ? 'border-red-400' : ''"
class="w-full border rounded px-3 py-2 text-xs font-mono" required></textarea>
<p x-show="templateError" x-text="templateError" class="text-red-500 text-xs mt-1"></p>
</div>
<div x-show="mostrarPreview" x-cloak>
<div class="flex items-center justify-between mb-1">
<label class="text-xs font-medium text-gray-600">Vista previa (con datos de ejemplo)</label>
<span x-show="previewCargando" class="text-xs text-gray-400">actualizando…</span>
</div>
<!-- sandbox sin allow-scripts ni allow-same-origin: el HTML lo
escribe un admin, pero no tiene por qué correr con los
permisos del panel. -->
<iframe x-ref="preview" sandbox="" class="w-full h-[26rem] border rounded bg-white"></iframe>
<p x-show="previewError" x-text="previewError" class="text-red-500 text-xs mt-1"></p>
<p class="text-[11px] text-gray-400 mt-1">
Cliente, ítems y totales son inventados; al generar el documento real se reemplazan por los del cliente.
</p>
</div>
</div> </div>
<div class="flex justify-end gap-2 mt-5"> <div class="flex justify-end gap-2 mt-5">
<button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button> <button type="button" @click="closeModals()" class="px-4 py-2 border rounded text-sm">Cancelar</button>
@@ -154,6 +201,8 @@ document.addEventListener('alpine:init', () => {
datos: [], total: 0, totalPages: 1, page: 1, limit: 20, filtroTipo: '', datos: [], total: 0, totalPages: 1, page: 1, limit: 20, filtroTipo: '',
addModal: false, editModal: false, deleteModal: false, addModal: false, editModal: false, deleteModal: false,
selectedId: null, templateError: '', selectedId: null, templateError: '',
importando: false, avisoImport: '', errorImport: false,
mostrarPreview: false, previewCargando: false, previewError: '',
form: { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true }, form: { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true },
toast: { show:false, msg:'', type:'ok' }, toast: { show:false, msg:'', type:'ok' },
@@ -176,17 +225,68 @@ document.addEventListener('alpine:init', () => {
this.form = { nombre: d.nombre, tipo: d.tipo, contenido_html: d.contenido_html, version: d.version, activa: d.activa }; this.form = { nombre: d.nombre, tipo: d.tipo, contenido_html: d.contenido_html, version: d.version, activa: d.activa };
this.selectedId = d.ID; this.selectedId = d.ID;
this.editModal = true; this.editModal = true;
this.mostrarPreview = true;
this.$nextTick(() => this.previsualizar());
}, },
openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; }, openDelete(d) { this.selectedId = d.ID; this.deleteModal = true; },
async importar() {
const f = this.$refs.archivo?.files?.[0];
if (!f) { this.errorImport = true; this.avisoImport = 'Elegí un archivo primero'; return; }
this.importando = true; this.avisoImport = ''; this.errorImport = false;
try {
const fd = new FormData();
fd.append('archivo', f);
fd.append('tipo', this.form.tipo);
const { data } = await axios.post('/app/api/plantillas-documento/importar', fd);
this.form.contenido_html = data.contenido_html || '';
if (!this.form.nombre) this.form.nombre = f.name.replace(/\.[^.]+$/, '');
this.errorImport = !!data.aviso;
this.avisoImport = data.aviso || 'Listo, revisá cómo quedó';
this.mostrarPreview = true;
await this.previsualizar();
} catch(e) {
this.errorImport = true;
// Sin data.error el fallo no vino de la app sino del proxy
// (timeout, por ejemplo); mostrar el status ayuda a distinguirlo.
this.avisoImport = e.response?.data?.error
|| (e.response ? `El servidor respondió ${e.response.status}` : e.message);
}
this.importando = false;
},
async validar() { async validar() {
this.templateError = ''; this.templateError = '';
if (this.mostrarPreview) await this.previsualizar();
},
async previsualizar() {
if (!this.form.contenido_html) { this.pintarPreview(''); return; }
this.previewCargando = true; this.previewError = '';
try {
const { data } = await axios.post('/app/api/plantillas-documento/previsualizar', {
tipo: this.form.tipo,
contenido_html: this.form.contenido_html,
});
this.pintarPreview(data.html || '');
} catch (e) {
this.previewError = e.response?.data?.error || e.message;
}
this.previewCargando = false;
},
// srcdoc y no document.write: con el iframe en sandbox no hay acceso a
// su documento desde acá.
pintarPreview(html) {
if (this.$refs.preview) this.$refs.preview.srcdoc = html;
}, },
closeModals() { closeModals() {
this.addModal = this.editModal = this.deleteModal = false; this.addModal = this.editModal = this.deleteModal = false;
this.selectedId = null; this.selectedId = null;
this.templateError = ''; this.templateError = '';
this.avisoImport = ''; this.errorImport = false;
this.mostrarPreview = false; this.previewError = '';
this.form = { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true }; this.form = { nombre:'', tipo:'cotizacion', contenido_html:'', version:1, activa:true };
}, },
+1 -1
View File
@@ -74,7 +74,7 @@
class="ml-auto mr-3"> class="ml-auto mr-3">
<a x-show="tieneUmind" x-cloak href="/portal/studio" title="uMind Studio" <a x-show="tieneUmind" x-cloak href="/portal/studio" title="uMind Studio"
class="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 px-2 sm:px-3 py-1.5 rounded-lg hover:bg-slate-100 transition-colors"> class="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 px-2 sm:px-3 py-1.5 rounded-lg hover:bg-slate-100 transition-colors">
<span class="w-5 h-5 rounded-md flex items-center justify-center text-white text-[10px] font-bold shrink-0" style="background:#8eb02f">uM</span> <svg viewBox="0 0 96 96" class="w-5 h-5 shrink-0" aria-hidden="true"><rect width="96" height="96" rx="22" fill="#8eb02f"/><path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round"/><circle cx="60" cy="28" r="7" fill="#fff"/></svg>
<!-- En móvil queda solo el logo: el texto no entra al lado de la <!-- En móvil queda solo el logo: el texto no entra al lado de la
campana y el nombre de usuario, pero el acceso no puede faltar. --> campana y el nombre de usuario, pero el acceso no puede faltar. -->
<span class="hidden sm:inline">uMind Studio</span> <span class="hidden sm:inline">uMind Studio</span>
+2 -2
View File
@@ -27,7 +27,7 @@
confía; es el punto de contacto más barato que existe. --> confía; es el punto de contacto más barato que existe. -->
{{ if .tieneUmind }} {{ if .tieneUmind }}
<a href="/portal/studio" class="flex items-center gap-3 bg-white border border-slate-200 hover:border-[#8eb02f] rounded-2xl p-4 mb-6 transition-colors group"> <a href="/portal/studio" class="flex items-center gap-3 bg-white border border-slate-200 hover:border-[#8eb02f] rounded-2xl p-4 mb-6 transition-colors group">
<span class="w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold flex-shrink-0" style="background:#8eb02f">uM</span> <svg viewBox="0 0 96 96" class="w-10 h-10 flex-shrink-0" aria-hidden="true"><rect width="96" height="96" rx="22" fill="#8eb02f"/><path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round"/><circle cx="60" cy="28" r="7" fill="#fff"/></svg>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="font-semibold text-slate-800 text-sm">uMind Studio</p> <p class="font-semibold text-slate-800 text-sm">uMind Studio</p>
<p class="text-slate-500 text-xs mt-0.5">Configurá tus asistentes, revisá conversaciones y consumo.</p> <p class="text-slate-500 text-xs mt-0.5">Configurá tus asistentes, revisá conversaciones y consumo.</p>
@@ -37,7 +37,7 @@
{{ else }} {{ else }}
<div class="rounded-2xl p-5 mb-6 border border-slate-200 bg-gradient-to-br from-[#8eb02f]/8 to-transparent"> <div class="rounded-2xl p-5 mb-6 border border-slate-200 bg-gradient-to-br from-[#8eb02f]/8 to-transparent">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold flex-shrink-0" style="background:#8eb02f">uM</span> <svg viewBox="0 0 96 96" class="w-10 h-10 flex-shrink-0" aria-hidden="true"><rect width="96" height="96" rx="22" fill="#8eb02f"/><path d="M32,42 V58 A14,14 0 0 0 60,58 V42" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M60,58 V64" fill="none" stroke="#fff" stroke-width="10" stroke-linecap="round"/><circle cx="60" cy="28" r="7" fill="#fff"/></svg>
<div class="min-w-0"> <div class="min-w-0">
<p class="font-semibold text-slate-800 text-sm">Tu WhatsApp puede responder solo</p> <p class="font-semibold text-slate-800 text-sm">Tu WhatsApp puede responder solo</p>
<p class="text-slate-600 text-sm mt-1"> <p class="text-slate-600 text-sm mt-1">
+214
View File
@@ -108,6 +108,148 @@
</div> </div>
<p class="text-xs text-slate-400 mt-2">Si se deja vacío, se usará la configuración SMTP general del sistema.</p> <p class="text-xs text-slate-400 mt-2">Si se deja vacío, se usará la configuración SMTP general del sistema.</p>
<div class="flex flex-wrap gap-2 mt-3 items-center">
<input x-model="emailPrueba" type="email" placeholder="tu@correo.com"
class="border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
<button @click="probarEnvio()" :disabled="ocupado"
class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 disabled:opacity-50">
Enviar correo de prueba
</button>
<span x-show="mensajeEnvio" x-text="mensajeEnvio" class="text-sm"
:class="errorEnvio ? 'text-red-600' : 'text-green-600'"></span>
</div>
<p class="text-xs text-slate-400 mt-1">
Sale por el mismo camino que el acuse automático que recibe el cliente cuando se le crea un ticket.
Si esta prueba falla, ese acuse tampoco está llegando.
</p>
</div>
<!-- ─── IMAP entrante ────────────────────────────────────────── -->
<div class="border-t border-slate-200 pt-5">
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-semibold text-slate-700">Buzón entrante (IMAP)</p>
<label class="flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" x-model="cfg.imap_activo" class="rounded"> Leer el buzón cada 2 minutos
</label>
</div>
<p class="text-xs text-slate-400 mb-3">
Alternativa al webhook: en vez de esperar que el proveedor nos avise, entramos al buzón y bajamos
los correos sin leer. Cada uno abre un ticket (o se agrega al hilo si es respuesta) y queda marcado como leído.
</p>
<div class="grid md:grid-cols-3 gap-3">
<div class="md:col-span-2">
<label class="block text-xs font-medium text-slate-600 mb-1">Servidor IMAP</label>
<input x-model="cfg.imap_host" type="text" placeholder="imap.example.com"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Puerto</label>
<input x-model="cfg.imap_port" type="number" placeholder="993"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
</div>
<div class="grid md:grid-cols-2 gap-3 mt-2">
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Usuario</label>
<input x-model="cfg.imap_username" type="text" placeholder="soporte@u-s.app"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Contraseña</label>
<input x-model="cfg.imap_password" type="password"
:placeholder="tieneImapPassword ? 'Guardada — escribí una nueva para cambiarla' : '••••••••'"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
</div>
<div class="grid md:grid-cols-2 gap-3 mt-2">
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Encriptación</label>
<select x-model="cfg.imap_encryption" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
<option value="ssl">SSL/TLS (puerto 993)</option>
<option value="starttls">STARTTLS (puerto 143)</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-600 mb-1">Carpeta</label>
<input x-model="cfg.imap_carpeta" type="text" placeholder="INBOX"
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
</div>
</div>
<div class="mt-2">
<label class="block text-xs font-medium text-slate-600 mb-1">Antigüedad máxima</label>
<select x-model.number="cfg.imap_horas_atras" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
<option :value="6">Últimas 6 horas</option>
<option :value="12">Últimas 12 horas</option>
<option :value="24">Últimas 24 horas</option>
<option :value="72">Últimos 3 días</option>
<option :value="0">Todo lo que haya sin leer</option>
</select>
<p class="text-xs text-slate-400 mt-1">
Los correos más viejos que esto no se tocan, aunque estén sin leer. Es lo que evita que la
primera corrida convierta años de buzón en tickets.
</p>
</div>
<div class="flex flex-wrap gap-2 mt-3">
<button @click="probarImap()" :disabled="ocupado"
class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 disabled:opacity-50">
Probar conexión
</button>
<button @click="revisarAhora()" :disabled="ocupado"
class="px-4 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 disabled:opacity-50">
Revisar buzón ahora
</button>
<span x-show="mensajeImap" x-text="mensajeImap" class="text-sm self-center"
:class="errorImap ? 'text-red-600' : 'text-green-600'"></span>
</div>
<p class="text-xs text-slate-400 mt-2">Probá la conexión después de guardar: la contraseña se cifra al guardarse.</p>
</div>
<!-- ─── Filtro con IA ────────────────────────────────────────── -->
<div class="border-t border-slate-200 pt-5">
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-semibold text-slate-700">Filtrar con IA</p>
<label class="flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" x-model="cfg.clasificar_con_ia" class="rounded"> Detectar si el correo es de soporte
</label>
</div>
<p class="text-xs text-slate-400 mb-3">
Antes de abrir un ticket, la IA lee el correo y decide si es una solicitud de soporte o ruido
(newsletters, notificaciones automáticas, facturas de proveedores, spam).
<strong>Solo se filtra a los desconocidos</strong>: si el remitente es un cliente o un usuario del
portal, siempre abre ticket. Ante la duda también abre: es peor ignorar a alguien que tener un
ticket de más, y si la IA no responde el ticket se abre igual.
Lo descartado no abre ticket, pero el correo sigue en el buzón y "Revisar buzón ahora" te dice qué
dejó afuera y por qué.
</p>
<label class="block text-xs font-medium text-slate-600 mb-1">Contexto del negocio (opcional)</label>
<textarea x-model="cfg.contexto_negocio" rows="3"
placeholder="Ej: Somos una agencia de software. Nuestros clientes escriben por errores de sus sitios y facturación. Los correos de nuestros proveedores de hosting no son soporte."
class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none"></textarea>
<p class="text-xs text-slate-400 mt-1">Ayuda a la IA a distinguir tus casos raros. Los correos masivos se descartan por sus encabezados, sin gastar una llamada.</p>
</div>
<!-- ─── Borradores de respuesta ──────────────────────────────── -->
<div class="border-t border-slate-200 pt-5">
<p class="text-sm font-semibold text-slate-700 mb-1">Borradores de respuesta</p>
<p class="text-xs text-slate-400 mb-3">
En cada ticket aparece un botón <strong>✨ Borrador</strong> que propone una respuesta usando la base
de conocimiento del agente que elijas. <strong>Nunca la envía</strong>: cae en el cuadro de respuesta
para que la revises y la mandes vos. Sin agente elegido el borrador sale igual, pero solo con la
conversación del ticket.
</p>
<label class="block text-xs font-medium text-slate-600 mb-1">Agente de uMind</label>
<select x-model="cfg.agente_borrador_id" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none">
<option value="">Sin base de conocimiento</option>
<template x-for="a in agentes" :key="a.id">
<option :value="a.id" x-text="a.nombre"></option>
</template>
</select>
</div> </div>
<div class="pt-4"> <div class="pt-4">
@@ -146,8 +288,27 @@ function soporteWebhook() {
smtp_encryption: 'starttls', smtp_encryption: 'starttls',
smtp_from_addr: '', smtp_from_addr: '',
smtp_from_name: '', smtp_from_name: '',
imap_activo: false,
imap_host: '',
imap_port: 993,
imap_username: '',
imap_password: '',
imap_encryption: 'ssl',
imap_carpeta: 'INBOX',
imap_horas_atras: 12,
clasificar_con_ia: false,
contexto_negocio: '',
agente_borrador_id: '',
}, },
admins: [], admins: [],
agentes: [],
tieneImapPassword: false,
ocupado: false,
mensajeImap: '',
errorImap: false,
emailPrueba: '',
mensajeEnvio: '',
errorEnvio: false,
async init() { async init() {
try { try {
@@ -155,12 +316,21 @@ function soporteWebhook() {
if (r.data && r.data.data) { if (r.data && r.data.data) {
this.cfg = { ...this.cfg, ...r.data.data }; this.cfg = { ...this.cfg, ...r.data.data };
this.cfg.asignar_a = r.data.data.asignar_a || ''; this.cfg.asignar_a = r.data.data.asignar_a || '';
// gorm.Model serializa la clave primaria como "ID"; sin esto el
// guardado creaba una fila nueva cada vez y los cambios no se veían.
this.cfg.id = r.data.data.ID || r.data.data.id || 0;
this.cfg.imap_password = '';
this.tieneImapPassword = !!r.data.data.tiene_imap_password;
} }
} catch {} } catch {}
try { try {
const r = await axios.get('/app/tickets/admins'); const r = await axios.get('/app/tickets/admins');
this.admins = r.data || []; this.admins = r.data || [];
} catch {} } catch {}
try {
const r = await axios.get('/app/soporte/agentes');
this.agentes = r.data || [];
} catch {}
if (!this.cfg.api_key) { if (!this.cfg.api_key) {
this.cfg.api_key = this.generarClave(); this.cfg.api_key = this.generarClave();
} }
@@ -193,14 +363,58 @@ function soporteWebhook() {
smtp_encryption: this.cfg.smtp_encryption || 'starttls', smtp_encryption: this.cfg.smtp_encryption || 'starttls',
smtp_from_addr: this.cfg.smtp_from_addr || '', smtp_from_addr: this.cfg.smtp_from_addr || '',
smtp_from_name: this.cfg.smtp_from_name || '', smtp_from_name: this.cfg.smtp_from_name || '',
imap_activo: !!this.cfg.imap_activo,
imap_host: this.cfg.imap_host || '',
imap_port: parseInt(this.cfg.imap_port) || 993,
imap_username: this.cfg.imap_username || '',
imap_password: this.cfg.imap_password || '',
imap_encryption: this.cfg.imap_encryption || 'ssl',
imap_carpeta: this.cfg.imap_carpeta || 'INBOX',
imap_horas_atras: Number(this.cfg.imap_horas_atras) || 0,
clasificar_con_ia: !!this.cfg.clasificar_con_ia,
contexto_negocio: this.cfg.contexto_negocio || '',
agente_borrador_id: this.cfg.agente_borrador_id ? parseInt(this.cfg.agente_borrador_id) : null,
}; };
try { try {
await axios.post('/app/soporte/webhook', payload); await axios.post('/app/soporte/webhook', payload);
if (payload.imap_password) { this.tieneImapPassword = true; this.cfg.imap_password = ''; }
await this.init();
alert('Configuración guardada'); alert('Configuración guardada');
} catch (e) { } catch (e) {
alert('Error al guardar: ' + (e.response?.data?.error || e.message)); alert('Error al guardar: ' + (e.response?.data?.error || e.message));
} }
}, },
async llamarImap(url, okPorDefecto) {
this.ocupado = true;
this.mensajeImap = '';
try {
const r = await axios.post(url);
this.errorImap = false;
this.mensajeImap = r.data?.message || okPorDefecto;
} catch (e) {
this.errorImap = true;
this.mensajeImap = e.response?.data?.error || e.message;
}
this.ocupado = false;
},
async probarEnvio() {
this.ocupado = true;
this.mensajeEnvio = '';
try {
const r = await axios.post('/app/soporte/webhook/probar-envio', { email: this.emailPrueba });
this.errorEnvio = false;
this.mensajeEnvio = r.data?.message || 'Enviado';
} catch (e) {
this.errorEnvio = true;
this.mensajeEnvio = e.response?.data?.error || e.message;
}
this.ocupado = false;
},
probarImap() { return this.llamarImap('/app/soporte/webhook/probar-imap', 'Conexión correcta'); },
revisarAhora() { return this.llamarImap('/app/soporte/webhook/revisar-buzon', 'Buzón revisado'); },
}; };
} }
</script> </script>
+28 -4
View File
@@ -38,6 +38,12 @@
<span class="badge" :class="prioridadBadge(t.prioridad)" x-text="t.prioridad"></span> <span class="badge" :class="prioridadBadge(t.prioridad)" x-text="t.prioridad"></span>
<span x-show="t.origen==='email'" class="badge badge-default text-xs">📧 Email</span> <span x-show="t.origen==='email'" class="badge badge-default text-xs">📧 Email</span>
<span x-show="t.origen==='portal'" class="badge badge-default text-xs">🌐 Portal</span> <span x-show="t.origen==='portal'" class="badge badge-default text-xs">🌐 Portal</span>
<span x-show="t.categoria" class="badge badge-default text-xs" x-text="t.categoria"></span>
<!-- De quién es. Sin cliente resuelto es alguien de afuera: no es
un error, pero conviene que se vea distinto. -->
<span x-show="t.cliente" class="badge text-xs bg-indigo-100 text-indigo-700"
x-text="t.cliente ? '🏢 ' + t.cliente.nombre : ''"></span>
<span x-show="!t.cliente && t.origen==='email'" class="badge text-xs bg-slate-100 text-slate-500">👤 Externo</span>
</div> </div>
<h3 class="font-semibold text-sm text-slate-800 truncate" x-text="t.titulo"></h3> <h3 class="font-semibold text-sm text-slate-800 truncate" x-text="t.titulo"></h3>
<p class="text-xs text-slate-500 mt-0.5"> <p class="text-xs text-slate-500 mt-0.5">
@@ -97,10 +103,16 @@
<!-- Responder --> <!-- Responder -->
<div class="mt-3 flex gap-2"> <div class="mt-3 flex gap-2">
<input x-model="t._reply" type="text" placeholder="Escribir respuesta..." <!-- Textarea y no input: el borrador de la IA viene en varios renglones. -->
class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none" <textarea x-model="t._reply" rows="2" placeholder="Escribir respuesta..."
@keydown.enter.prevent="responder(t)" class="flex-1 border border-slate-200 rounded-xl px-3 py-2 text-sm outline-none resize-y"
onfocus="this.style.borderColor='#8eb02f'" onblur="this.style.borderColor='#e2e8f0'"> @keydown.enter.exact.prevent="responder(t)"
onfocus="this.style.borderColor='#8eb02f'" onblur="this.style.borderColor='#e2e8f0'"></textarea>
<button @click="redactar(t)" :disabled="t._redactando"
title="Propone una respuesta con la base de conocimiento. No la envía."
class="px-3 py-2 rounded-xl border border-slate-300 text-sm text-slate-700 disabled:opacity-50 flex-shrink-0">
<span x-text="t._redactando ? 'Redactando…' : '✨ Borrador'"></span>
</button>
<button @click="responder(t)" <button @click="responder(t)"
class="px-4 py-2 rounded-xl text-white text-sm font-medium transition-colors" class="px-4 py-2 rounded-xl text-white text-sm font-medium transition-colors"
style="background:#8eb02f" style="background:#8eb02f"
@@ -190,6 +202,18 @@ function ticketsAdmin() {
} }
}, },
async redactar(t) {
t._redactando = true;
try {
const { data } = await axios.post(`/app/tickets/${t.ID}/borrador`);
// Se escribe en el cuadro de respuesta, no se envía: lo revisa una persona.
t._reply = data.borrador || '';
} catch (e) {
alert(e.response?.data?.error || e.message);
}
t._redactando = false;
},
async responder(t) { async responder(t) {
const contenido = (t._reply || '').trim(); const contenido = (t._reply || '').trim();
if (!contenido) return; if (!contenido) return;
+36 -9
View File
@@ -50,7 +50,13 @@ func GetAiConfigs(c *fiber.Ctx) error {
Modulo string `json:"modulo"` Modulo string `json:"modulo"`
EsAgenteBot bool `json:"es_agente_bot"` EsAgenteBot bool `json:"es_agente_bot"`
TelegramConfigID *uint `json:"telegram_config_id"` TelegramConfigID *uint `json:"telegram_config_id"`
TenantID *uint `json:"tenant_id"`
TenantNombre string `json:"tenant_nombre"`
} }
// Los nombres de los espacios, en una consulta: sin esto la pantalla
// mostraría "tenant 7" y nadie sabe de quién es el 7.
nombresTenant := models.NombresDeTenantsUmind()
safeItems := make([]safe, len(items)) safeItems := make([]safe, len(items))
for i, it := range items { for i, it := range items {
// El hint sale de la clave en claro: sobre el ciphertext mostraría // El hint sale de la clave en claro: sobre el ciphertext mostraría
@@ -72,6 +78,10 @@ func GetAiConfigs(c *fiber.Ctx) error {
Modulo: it.Modulo, Modulo: it.Modulo,
EsAgenteBot: it.EsAgenteBot, EsAgenteBot: it.EsAgenteBot,
TelegramConfigID: it.TelegramConfigID, TelegramConfigID: it.TelegramConfigID,
TenantID: it.TenantID,
}
if it.TenantID != nil {
safeItems[i].TenantNombre = nombresTenant[*it.TenantID]
} }
} }
@@ -126,6 +136,9 @@ func CreateAiConfigHandler(c *fiber.Ctx) error {
if err := models.CreateAiConfig(&item); err != nil { if err := models.CreateAiConfig(&item); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
if item.EsAgenteBot {
models.QuitarAgenteBotSalvo(item.ID)
}
return c.JSON(fiber.Map{"ok": true, "id": item.ID}) return c.JSON(fiber.Map{"ok": true, "id": item.ID})
} }
@@ -153,21 +166,35 @@ func UpdateAiConfigHandler(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
} }
// Qué campos vinieron de verdad. Sin esto, un formulario que no manda
// es_agente_bot lo guardaba en false: editar cualquier config desde el
// panel dejaba al bot de Telegram y al chat del dashboard sin cerebro.
var presentes map[string]json.RawMessage
_ = json.Unmarshal(c.Body(), &presentes)
vino := func(campo string) bool { _, ok := presentes[campo]; return ok }
updates := map[string]interface{}{ updates := map[string]interface{}{
"nombre": strings.TrimSpace(req.Nombre), "nombre": strings.TrimSpace(req.Nombre),
"provider": strings.ToLower(strings.TrimSpace(req.Provider)), "provider": strings.ToLower(strings.TrimSpace(req.Provider)),
"base_url": strings.TrimSpace(req.BaseURL), "base_url": strings.TrimSpace(req.BaseURL),
"model_name": strings.TrimSpace(req.ModelName), "model_name": strings.TrimSpace(req.ModelName),
"is_active": req.IsActive, "is_active": req.IsActive,
"notes": req.Notes, "notes": req.Notes,
"modulo": models.JoinModulos(strings.Split(req.Modulo, ",")), "modulo": models.JoinModulos(strings.Split(req.Modulo, ",")),
"es_agente_bot": req.EsAgenteBot, }
"telegram_config_id": req.TelegramConfigID, if vino("es_agente_bot") {
updates["es_agente_bot"] = req.EsAgenteBot
}
if vino("telegram_config_id") {
updates["telegram_config_id"] = req.TelegramConfigID
} }
if strings.TrimSpace(req.ApiKey) != "" { if strings.TrimSpace(req.ApiKey) != "" {
updates["api_key"] = models.CifrarClaveAi(strings.TrimSpace(req.ApiKey)) updates["api_key"] = models.CifrarClaveAi(strings.TrimSpace(req.ApiKey))
} }
if req.EsAgenteBot && vino("es_agente_bot") {
models.QuitarAgenteBotSalvo(uint(id))
}
if err := models.UpdateAiConfig(uint(id), updates); err != nil { if err := models.UpdateAiConfig(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
+52
View File
@@ -0,0 +1,52 @@
package controllers
import (
"log"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
)
// GenerarTextoIA corre una generación con la configuración de IA que el staff
// eligió en /app/ai-config para el módulo "ia" (el que la pantalla llama
// "IA / vCard").
//
// La clave del proveedor no sale de acá: la integración manda su prompt con su
// propia API key de /api/v2 y recibe el texto. Es la diferencia con devolverle
// la configuración —incluida la clave de OpenAI o Anthropic— a otra aplicación.
//
// POST /api/v2/vcard/ia {"prompt": "...", "sistema": "..."}
func GenerarTextoIA(c *fiber.Ctx) error {
var body struct {
Prompt string `json:"prompt"`
Sistema string `json:"sistema"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
}
if body.Prompt == "" {
return c.Status(400).JSON(fiber.Map{"error": "prompt requerido"})
}
if body.Sistema == "" {
body.Sistema = "Respondé de forma clara y breve."
}
// El módulo va fijo: si lo eligiera quien llama, una llave con scope vcard
// podría usar la configuración del agente o la del Landing Generator.
texto, err := services.CompletarTextoIA("ia", body.Sistema, body.Prompt)
if err != nil {
// Al log también: la integración suele mostrar solo el código de estado,
// y sin esta línea el motivo no queda registrado en ningún lado.
log.Printf("[IA v2] falló la generación: %v", err)
// 503 cuando falta configurar algo de este lado y 502 cuando el que
// falló fue el proveedor: son problemas de dueños distintos.
estado := 502
if strings.Contains(err.Error(), "configuración de IA") {
estado = 503
}
return c.Status(estado).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"texto": texto})
}
+28
View File
@@ -2,6 +2,7 @@ package controllers
import ( import (
"encoding/json" "encoding/json"
"strings"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
@@ -38,3 +39,30 @@ func getOSSFromQuery(c *fiber.Ctx) (services.OSSProvider, error) {
} }
return services.NewOSSProvider(lastActive) return services.NewOSSProvider(lastActive)
} }
// claveObjetoSegura arma una clave de objeto a partir de texto que viene del
// cliente. Sin esto, un nombre con "/" o con caracteres de control produce una
// clave que OSS rechaza — y el que llama solo ve "error subiendo a OSS".
func claveObjetoSegura(nombre string) string {
nombre = strings.TrimSpace(nombre)
limpio := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return r
case r == '-', r == '_', r == '.':
return r
case r == ' ':
return '_'
default:
return -1
}
}, nombre)
limpio = strings.Trim(limpio, "._-")
if limpio == "" {
return "sin-nombre"
}
if len(limpio) > 80 {
limpio = limpio[:80]
}
return limpio
}
+26
View File
@@ -0,0 +1,26 @@
package controllers
import "testing"
// La clave de objeto se arma con texto que manda el cliente. Antes iba tal
// cual: un nombre con "/" o con acentos raros producía una clave que OSS
// rechaza, y del otro lado solo se veía "error subiendo archivo a OSS".
func TestClaveObjetoSegura(t *testing.T) {
casos := map[string]string{
"Juan Pérez": "Juan_Prez",
"Ana/María": "AnaMara",
"../../etc/pass": "etcpass",
" ": "sin-nombre",
"": "sin-nombre",
"ok-nombre_1.2": "ok-nombre_1.2",
"emoji 🚀 fin": "emoji__fin",
}
for in, want := range casos {
if got := claveObjetoSegura(in); got != want {
t.Errorf("claveObjetoSegura(%q) = %q, want %q", in, got, want)
}
}
if got := claveObjetoSegura(string(make([]byte, 0)) + "a" + string(rune(0))); got != "a" {
t.Errorf("caracter de control no filtrado: %q", got)
}
}
@@ -16,15 +16,18 @@ type umindTgChat struct {
} }
type umindTgFileRef struct { type umindTgFileRef struct {
FileID string `json:"file_id"` FileID string `json:"file_id"`
FileName string `json:"file_name"` // solo lo traen los documentos
} }
type umindTgMessage struct { type umindTgMessage struct {
Chat umindTgChat `json:"chat"` Chat umindTgChat `json:"chat"`
Text string `json:"text"` Text string `json:"text"`
Voice *umindTgFileRef `json:"voice"` Voice *umindTgFileRef `json:"voice"`
Audio *umindTgFileRef `json:"audio"` Audio *umindTgFileRef `json:"audio"`
Photo []umindTgFileRef `json:"photo"` Photo []umindTgFileRef `json:"photo"`
Document *umindTgFileRef `json:"document"`
Caption string `json:"caption"`
} }
type umindTgUpdate struct { type umindTgUpdate struct {
@@ -55,6 +58,8 @@ func UmindTelegramWebhook(c *fiber.Ctx) error {
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Audio.FileID, "audio") err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Audio.FileID, "audio")
case len(msg.Photo) > 0: case len(msg.Photo) > 0:
err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Photo[len(msg.Photo)-1].FileID, "image") err = services.ProcesarMediaTelegramUmind(canal, msg.Chat.ID, msg.Photo[len(msg.Photo)-1].FileID, "image")
case msg.Document != nil && msg.Document.FileID != "":
err = services.ProcesarMediaTelegramUmindConNombre(canal, msg.Chat.ID, msg.Document.FileID, "document", msg.Document.FileName, msg.Caption)
default: default:
texto := strings.TrimSpace(msg.Text) texto := strings.TrimSpace(msg.Text)
if texto == "" { if texto == "" {
@@ -81,8 +86,13 @@ type umindWaMessage struct {
Text struct { Text struct {
Body string `json:"body"` Body string `json:"body"`
} `json:"text"` } `json:"text"`
Image umindWaMediaRef `json:"image"` Image umindWaMediaRef `json:"image"`
Audio umindWaMediaRef `json:"audio"` Audio umindWaMediaRef `json:"audio"`
Document struct {
ID string `json:"id"`
Filename string `json:"filename"`
Caption string `json:"caption"`
} `json:"document"`
} }
type umindWaValue struct { type umindWaValue struct {
@@ -172,6 +182,11 @@ func UmindWhatsAppWebhook(c *fiber.Ctx) error {
continue continue
} }
err = services.ProcesarMediaWhatsAppUmind(canal, msg.From, msg.Image.ID, "image") err = services.ProcesarMediaWhatsAppUmind(canal, msg.From, msg.Image.ID, "image")
case "document":
if msg.Document.ID == "" {
continue
}
err = services.ProcesarMediaWhatsAppUmindConNombre(canal, msg.From, msg.Document.ID, "document", msg.Document.Filename, msg.Document.Caption)
default: default:
continue continue
} }
@@ -139,6 +139,16 @@ const umindWidgetJS = `(function () {
'.umind-msg-user{color:#fff;margin-left:auto;border-bottom-right-radius:4px;}', '.umind-msg-user{color:#fff;margin-left:auto;border-bottom-right-radius:4px;}',
'.umind-msg-bot a{color:var(--umind-color);text-decoration:underline;word-break:break-all;}', '.umind-msg-bot a{color:var(--umind-color);text-decoration:underline;word-break:break-all;}',
'.umind-msg-user a{color:#fff;text-decoration:underline;word-break:break-all;}', '.umind-msg-user a{color:#fff;text-decoration:underline;word-break:break-all;}',
// "Escribiendo": los tres puntos que cualquiera reconoce de WhatsApp. Sin
// esto, entre que la persona manda y el agente contesta —varios segundos
// si tiene que buscar en el conocimiento— el widget se queda mudo y parece
// colgado.
'.umind-escribiendo{display:flex;gap:4px;align-items:center;width:fit-content;margin-bottom:10px;padding:11px 13px;background:#fff;border:1px solid #ececec;border-radius:13px;border-bottom-left-radius:4px;}',
'.umind-escribiendo i{width:6px;height:6px;border-radius:50%;background:#b6b6b6;display:block;animation:umind-punto 1.3s infinite;}',
'.umind-escribiendo i:nth-child(2){animation-delay:.18s;}',
'.umind-escribiendo i:nth-child(3){animation-delay:.36s;}',
'@keyframes umind-punto{0%,60%,100%{opacity:.3;transform:translateY(0);}30%{opacity:1;transform:translateY(-3px);}}',
'@media (prefers-reduced-motion:reduce){.umind-escribiendo i{animation:none;opacity:.6;}}',
'.umind-inputbar{display:flex;align-items:center;border-top:1px solid #eee;padding:8px;gap:6px;background:#fff;flex-shrink:0;}', '.umind-inputbar{display:flex;align-items:center;border-top:1px solid #eee;padding:8px;gap:6px;background:#fff;flex-shrink:0;}',
'.umind-inputbar input{flex:1;border:1px solid #e5e5e5;border-radius:20px;padding:9px 14px;font-size:13px;outline:none;}', '.umind-inputbar input{flex:1;border:1px solid #e5e5e5;border-radius:20px;padding:9px 14px;font-size:13px;outline:none;}',
'.umind-inputbar button{border:none;color:#fff;width:34px;height:34px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;}', '.umind-inputbar button{border:none;color:#fff;width:34px;height:34px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;}',
@@ -225,13 +235,38 @@ const umindWidgetJS = `(function () {
} }
} }
// El indicador es un elemento suelto que se agrega y se saca: así no hay que
// llevar estado ni limpiar nada si la petición falla a mitad de camino.
var escribiendoEl = null;
function mostrarEscribiendo() {
if (escribiendoEl) return;
escribiendoEl = document.createElement('div');
escribiendoEl.className = 'umind-escribiendo';
escribiendoEl.setAttribute('aria-label', 'El asistente está escribiendo');
for (var i = 0; i < 3; i++) escribiendoEl.appendChild(document.createElement('i'));
msgsEl.appendChild(escribiendoEl);
msgsEl.scrollTop = msgsEl.scrollHeight;
}
function ocultarEscribiendo() {
if (!escribiendoEl) return;
if (escribiendoEl.parentNode) escribiendoEl.parentNode.removeChild(escribiendoEl);
escribiendoEl = null;
}
function agregarMensaje(texto, quien) { function agregarMensaje(texto, quien) {
var burbuja = document.createElement('div'); var burbuja = document.createElement('div');
pintarConEnlaces(burbuja, texto == null ? '' : texto); pintarConEnlaces(burbuja, texto == null ? '' : texto);
var esUser = quien === 'user'; var esUser = quien === 'user';
burbuja.className = 'umind-msg ' + (esUser ? 'umind-msg-user' : 'umind-msg-bot'); burbuja.className = 'umind-msg ' + (esUser ? 'umind-msg-user' : 'umind-msg-bot');
if (esUser) burbuja.style.background = color; if (esUser) burbuja.style.background = color;
msgsEl.appendChild(burbuja); // Antes del indicador, para que los puntitos queden siempre al final.
if (escribiendoEl && escribiendoEl.parentNode === msgsEl) {
msgsEl.insertBefore(burbuja, escribiendoEl);
} else {
msgsEl.appendChild(burbuja);
}
msgsEl.scrollTop = msgsEl.scrollHeight; msgsEl.scrollTop = msgsEl.scrollHeight;
} }
@@ -274,6 +309,13 @@ const umindWidgetJS = `(function () {
if (!texto) return; if (!texto) return;
inputEl.value = ''; inputEl.value = '';
agregarMensaje(texto, 'user'); agregarMensaje(texto, 'user');
mostrarEscribiendo();
// Se bloquea el envío mientras responde: mandar tres mensajes seguidos
// desordena el hilo y multiplica el consumo sin que nadie lo pida.
inputEl.disabled = true;
sendEl.disabled = true;
sendEl.style.opacity = '.5';
fetch(apiBase + '/mensaje', { fetch(apiBase + '/mensaje', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -290,6 +332,16 @@ const umindWidgetJS = `(function () {
? 'El asistente no está disponible en este momento.' ? 'El asistente no está disponible en este momento.'
: 'Error de conexión, intenta de nuevo.', : 'Error de conexión, intenta de nuevo.',
'bot'); 'bot');
})
.then(function () {
// Pase lo que pase: si esto quedara solo en el .then de éxito, un
// error dejaría los puntitos animándose para siempre y el campo de
// texto bloqueado.
ocultarEscribiendo();
inputEl.disabled = false;
sendEl.disabled = false;
sendEl.style.opacity = '';
inputEl.focus();
}); });
} }
+21 -38
View File
@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"image/png" "image/png"
"log" "log"
"os"
"strings" "strings"
"github.com/chai2010/webp" "github.com/chai2010/webp"
@@ -53,29 +52,24 @@ func CreateQr(c *fiber.Ctx) error {
} }
random := helpers.RandomString(4) random := helpers.RandomString(4)
fileName := fmt.Sprintf("qrs/qr-%s-%s.webp", strings.ReplaceAll(data.FirstName, " ", "_"), random) fileName := fmt.Sprintf("qrs/qr-%s-%s.webp", claveObjetoSegura(data.FirstName), random)
tmpPath := fmt.Sprintf("/tmp/%s.webp", data.FirstName)
// 4. Guardar archivo temporal // 4. Subir a OSS directo desde memoria: el archivo temporal solo agregaba
if err := os.WriteFile(tmpPath, webpBuf.Bytes(), 0644); err != nil { // una forma más de fallar (y el nombre venía del cliente, sin limpiar).
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "No se pudo guardar el QR temporalmente",
})
}
defer os.Remove(tmpPath)
// 5. Subir a OSS
ossProvider, err := getOSSFromBody(c) ossProvider, err := getOSSFromBody(c)
if err != nil { if err != nil {
log.Printf("[QR] No se pudo resolver la config de OSS: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error de conexión con OSS", "error": "Error de conexión con OSS", "detalle": err.Error(),
}) })
} }
if err := ossProvider.UploadFile(fileName, tmpPath); err != nil { if err := ossProvider.UploadFromReader(fileName, "image/webp", bytes.NewReader(webpBuf.Bytes())); err != nil {
log.Printf("Error subiendo archivo a OSS: %v", err) log.Printf("Error subiendo archivo a OSS (%s): %v", fileName, err)
// El detalle viaja al que llama: es una API entre servidores y sin esto
// el otro lado solo ve "error subiendo a OSS" y no puede hacer nada.
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error subiendo archivo a OSS", "error": "Error subiendo archivo a OSS", "detalle": err.Error(),
}) })
} }
@@ -172,7 +166,7 @@ func CreateQrTmp(c *fiber.Ctx) error {
func CreateUrlQr(c *fiber.Ctx) error { func CreateUrlQr(c *fiber.Ctx) error {
var data struct { var data struct {
URL string `json:"url"` URL string `json:"url"`
Nombre string `json:"unico"` Nombre string `json:"unico"`
} }
if err := json.Unmarshal(c.Body(), &data); err != nil { if err := json.Unmarshal(c.Body(), &data); err != nil {
@@ -187,14 +181,14 @@ func CreateUrlQr(c *fiber.Ctx) error {
"error": "No se pudo generar el QR", "error": "No se pudo generar el QR",
}) })
} }
img, err := png.Decode(bytes.NewReader(qrBytes)) img, err := png.Decode(bytes.NewReader(qrBytes))
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "No se pudo procesar la imagen", "error": "No se pudo procesar la imagen",
}) })
} }
// Codificar a WebP // Codificar a WebP
var webpBuffer bytes.Buffer var webpBuffer bytes.Buffer
if err := webp.Encode(&webpBuffer, img, &webp.Options{Lossless: true}); err != nil { if err := webp.Encode(&webpBuffer, img, &webp.Options{Lossless: true}); err != nil {
@@ -202,7 +196,7 @@ func CreateUrlQr(c *fiber.Ctx) error {
"error": "No se pudo convertir a WebP", "error": "No se pudo convertir a WebP",
}) })
} }
// 3. Convertir a WebP // 3. Convertir a WebP
var webpBuf bytes.Buffer var webpBuf bytes.Buffer
if err := webp.Encode(&webpBuf, img, nil); err != nil { if err := webp.Encode(&webpBuf, img, nil); err != nil {
@@ -211,40 +205,29 @@ func CreateUrlQr(c *fiber.Ctx) error {
}) })
} }
fileName := fmt.Sprintf("qrs/url/qr-%s.webp", claveObjetoSegura(data.Nombre))
fileName := fmt.Sprintf("qrs/url/qr-%s.webp", strings.ReplaceAll(data.Nombre, " ", "_") ) // 4. Subir a OSS directo desde memoria (ver el comentario en CreateQr).
tmpPath := fmt.Sprintf("/tmp/%s.webp", data.Nombre)
// 4. Guardar archivo temporal
if err := os.WriteFile(tmpPath, webpBuf.Bytes(), 0644); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "No se pudo guardar el QR temporalmente",
})
}
defer os.Remove(tmpPath)
// 5. Subir a OSS
ossProvider, err := getOSSFromBody(c) ossProvider, err := getOSSFromBody(c)
if err != nil { if err != nil {
log.Printf("[QR URL] No se pudo resolver la config de OSS: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error de conexión con OSS", "error": "Error de conexión con OSS", "detalle": err.Error(),
}) })
} }
if err := ossProvider.UploadFile(fileName, tmpPath); err != nil { if err := ossProvider.UploadFromReader(fileName, "image/webp", bytes.NewReader(webpBuf.Bytes())); err != nil {
log.Printf("Error subiendo archivo a OSS: %v", err) log.Printf("Error subiendo archivo a OSS (%s): %v", fileName, err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error subiendo archivo a OSS", "error": "Error subiendo archivo a OSS", "detalle": err.Error(),
}) })
} }
// 6. URL del archivo // 6. URL del archivo
url := ossProvider.PublicURL(fileName) url := ossProvider.PublicURL(fileName)
// 8. Retornar la URL // 8. Retornar la URL
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"url": url, "url": url,
}) })
} }
+8 -17
View File
@@ -1,12 +1,11 @@
package controllers package controllers
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log" "log"
"os"
"strings"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/app"
@@ -34,29 +33,21 @@ func CreateVcf(c *fiber.Ctx) error {
} }
random := helpers.RandomString(4) random := helpers.RandomString(4)
fileName := fmt.Sprintf("vcf/vcf-%s-%s.vcf", strings.ReplaceAll(data.FirstName, " ", "_"), random) fileName := fmt.Sprintf("vcf/vcf-%s-%s.vcf", claveObjetoSegura(data.FirstName), random)
tmpPath := fmt.Sprintf("/tmp/vcf-%s-%s.vcf", strings.ReplaceAll(data.FirstName, " ", "_"), random)
// 2. Guardar archivo temporal // 2. Subir a OSS directo desde memoria (ver el comentario en CreateQr).
if err := os.WriteFile(tmpPath, vcfContent, 0644); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "No se pudo guardar el VCF temporalmente",
})
}
defer os.Remove(tmpPath)
// 3. Subir a OSS
ossProvider, err := getOSSFromBody(c) ossProvider, err := getOSSFromBody(c)
if err != nil { if err != nil {
log.Printf("[VCF] No se pudo resolver la config de OSS: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error de conexión con OSS", "error": "Error de conexión con OSS", "detalle": err.Error(),
}) })
} }
if err := ossProvider.UploadFile(fileName, tmpPath); err != nil { if err := ossProvider.UploadFromReader(fileName, "text/vcard; charset=utf-8", bytes.NewReader(vcfContent)); err != nil {
log.Printf("Error subiendo archivo a OSS: %v", err) log.Printf("Error subiendo archivo a OSS (%s): %v", fileName, err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Error subiendo archivo a OSS", "error": "Error subiendo archivo a OSS", "detalle": err.Error(),
}) })
} }
@@ -2,6 +2,7 @@ package controllers
import ( import (
"math" "math"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -17,6 +18,20 @@ var scopesValidos = map[string]bool{
"query_runner": true, "query_runner": true,
"usuarios": true, "usuarios": true,
"pasarelas": true, "pasarelas": true,
"vcard": true,
}
// ScopesDisponibles devuelve la lista para que la vista la muestre en vez de
// tener su propia copia: la copia de la vista ya ofreció "vcard" cuando el
// backend todavía lo rechazaba.
// GET /app/api-keys/scopes
func ScopesDisponibles(c *fiber.Ctx) error {
lista := make([]string, 0, len(scopesValidos))
for s := range scopesValidos {
lista = append(lista, s)
}
sort.Strings(lista)
return c.JSON(lista)
} }
// ApiKeysIndex renderiza el panel de administración de API keys. // ApiKeysIndex renderiza el panel de administración de API keys.
+16
View File
@@ -0,0 +1,16 @@
package controllers
import "testing"
// La pantalla ofrecía "vcard" mientras el backend lo rechazaba al guardar, y el
// único síntoma era "scope inválido: vcard" al crear la llave. Los scopes que
// las rutas de /api/v2 exigen con RequireScope tienen que poder asignarse.
func TestScopesDeLasRutasSonAsignables(t *testing.T) {
// Sacados de los RequireScope() de rest/routes.
usadosEnRutas := []string{"oss", "query_runner", "usuarios", "pasarelas", "vcard"}
for _, s := range usadosEnRutas {
if !scopesValidos[s] {
t.Errorf("las rutas exigen el scope %q pero no se puede asignar a una API key", s)
}
}
}
@@ -441,6 +441,7 @@ func AdminApiSpec(c *fiber.Ctx) error {
{"method": "POST", "path": "/api/v2/vcard/qr", "desc": "QR de contacto — devuelve imagen image/webp, NO JSON"}, {"method": "POST", "path": "/api/v2/vcard/qr", "desc": "QR de contacto — devuelve imagen image/webp, NO JSON"},
{"method": "POST", "path": "/api/v2/vcard/qr-url", "desc": "QR de una URL → {\"url\": \"...\"}"}, {"method": "POST", "path": "/api/v2/vcard/qr-url", "desc": "QR de una URL → {\"url\": \"...\"}"},
{"method": "POST", "path": "/api/v2/vcard/vcf", "desc": "Generar archivo .vcf → {\"success\": true, \"url\": \"...\"}"}, {"method": "POST", "path": "/api/v2/vcard/vcf", "desc": "Generar archivo .vcf → {\"success\": true, \"url\": \"...\"}"},
{"method": "POST", "path": "/api/v2/vcard/ia", "desc": "Generar texto con la IA configurada en /app/ai-config (módulo 'ia') → {\"texto\": \"...\"}"},
{"method": "POST", "path": "/api/v2/vcard/dlocal/planes", "desc": "Crear plan de suscripción"}, {"method": "POST", "path": "/api/v2/vcard/dlocal/planes", "desc": "Crear plan de suscripción"},
{"method": "GET", "path": "/api/v2/vcard/dlocal/planes", "desc": "Listar planes"}, {"method": "GET", "path": "/api/v2/vcard/dlocal/planes", "desc": "Listar planes"},
{"method": "GET", "path": "/api/v2/vcard/dlocal/planes/:planID", "desc": "Ver un plan"}, {"method": "GET", "path": "/api/v2/vcard/dlocal/planes/:planID", "desc": "Ver un plan"},
@@ -1,12 +1,15 @@
package controllers package controllers
import ( import (
"io"
"log"
"math" "math"
"strconv" "strconv"
"text/template" "text/template"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
) )
// ─── Plantillas de documento (cotización, contrato, acta, cuenta de cobro) ──── // ─── Plantillas de documento (cotización, contrato, acta, cuenta de cobro) ────
@@ -103,6 +106,69 @@ func DeletePlantillaDocumento(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "Eliminado", "ok": true}) return c.JSON(fiber.Map{"message": "Eliminado", "ok": true})
} }
// ImportarPlantillaDocumento recibe un documento (docx, html, txt o imagen), lo
// lee y le pide a la IA que lo devuelva como plantilla HTML con las variables
// del generador. NO guarda nada: el HTML vuelve al editor para revisarlo.
// POST /app/api/plantillas-documento/importar (multipart: archivo, tipo)
func ImportarPlantillaDocumento(c *fiber.Ctx) error {
archivo, err := c.FormFile("archivo")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Subí un archivo en el campo 'archivo'"})
}
if archivo.Size > 10<<20 {
return c.Status(400).JSON(fiber.Map{"error": "El archivo supera los 10 MB"})
}
f, err := archivo.Open()
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "No se pudo leer el archivo"})
}
defer f.Close()
datos, err := io.ReadAll(io.LimitReader(f, 10<<20))
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "No se pudo leer el archivo"})
}
texto, err := services.ExtraerTextoDePlantilla(archivo.Filename, datos)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
tipo := c.FormValue("tipo", "cotizacion")
html, err := services.ConvertirEnPlantilla(tipo, texto)
if err != nil {
// Al log también: el mensaje del proveedor es lo único que dice por qué
// falló, y desde el navegador se ve recortado.
log.Printf("[PLANTILLAS] importar (%s, %s): %v", tipo, archivo.Filename, err)
return c.Status(422).JSON(fiber.Map{"error": err.Error()})
}
// Si la IA devolvió algo que no compila, es mejor decirlo acá que al guardar.
if _, err := template.New("validate").Parse(html); err != nil {
return c.Status(200).JSON(fiber.Map{
"contenido_html": html,
"aviso": "La IA devolvió HTML con sintaxis de plantilla inválida (" + err.Error() + "). Revisalo antes de guardar.",
})
}
return c.JSON(fiber.Map{"contenido_html": html})
}
// PrevisualizarPlantillaDocumento renderiza la plantilla con datos de ejemplo
// para ver cómo queda antes de guardarla.
// POST /app/api/plantillas-documento/previsualizar {tipo, contenido_html}
func PrevisualizarPlantillaDocumento(c *fiber.Ctx) error {
var req struct {
Tipo string `json:"tipo"`
ContenidoHTML string `json:"contenido_html"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
}
html, err := services.RenderizarPlantillaEjemplo(req.Tipo, req.ContenidoHTML)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"html": html})
}
// ─── Tarifas ──────────────────────────────────────────────────────────────── // ─── Tarifas ────────────────────────────────────────────────────────────────
func GetTarifas(c *fiber.Ctx) error { func GetTarifas(c *fiber.Ctx) error {
+170 -139
View File
@@ -2,6 +2,7 @@ package controllers
import ( import (
"crypto/subtle" "crypto/subtle"
"fmt"
"log" "log"
"regexp" "regexp"
"strconv" "strconv"
@@ -28,7 +29,6 @@ type soporteEmailIn struct {
MessageID string `json:"message_id" form:"message_id"` MessageID string `json:"message_id" form:"message_id"`
} }
var ticketRefRe = regexp.MustCompile(`(?i)\[Ticket #(\d+)\]`)
var messageIDHeaderRe = regexp.MustCompile(`(?im)^Message-ID:\s*(<[^>\r\n]+>)`) var messageIDHeaderRe = regexp.MustCompile(`(?im)^Message-ID:\s*(<[^>\r\n]+>)`)
// validarWebhookKey compara la key recibida (query ?key= o header X-Webhook-Key / // validarWebhookKey compara la key recibida (query ?key= o header X-Webhook-Key /
@@ -121,111 +121,19 @@ func SoporteWebhook(c *fiber.Ctx) error {
} }
for _, e := range emails { for _, e := range emails {
if e.From == "" || e.Subject == "" {
continue
}
// Deduplicación: el proveedor puede reintentar la entrega del mismo correo.
if models.EmailMessageIDYaProcesado(e.MessageID) {
log.Printf("[SoporteWebhook] Correo duplicado ignorado (message_id=%s)", e.MessageID)
continue
}
fromEmail := extractEmail(e.From)
fromName := e.FromName
if fromName == "" {
fromName = extractName(e.From)
}
if fromName == "" {
fromName = fromEmail
}
contenido := e.Text contenido := e.Text
if contenido == "" { if contenido == "" {
contenido = e.Html contenido = e.Html
} }
contenido = strings.TrimSpace(contenido) services.IngestarCorreoSoporte(cfg, services.CorreoSoporte{
if len(contenido) > 5000 { From: e.From, FromName: e.FromName, Subject: e.Subject,
contenido = contenido[:5000] Texto: contenido, MessageID: e.MessageID,
} })
// Enhebrado: si el asunto trae "[Ticket #N]" (lo agregamos nosotros en el
// auto-ack) y ese ticket es del mismo remitente, es una respuesta — se
// agrega como mensaje en vez de abrir un ticket nuevo. Si no hay token
// pero el remitente tiene un ticket abierto reciente, también se enhebra.
if hilo := buscarTicketDeHilo(fromEmail, e.Subject); hilo != nil {
msg := &models.TicketMensaje{
TicketID: hilo.ID,
Contenido: contenido,
EsAdmin: false,
AutorNombre: fromName,
MessageID: e.MessageID,
}
if err := models.CreateTicketMensaje(msg); err != nil {
log.Printf("[SoporteWebhook] Error agregando mensaje al ticket #%d: %v", hilo.ID, err)
continue
}
if hilo.Estado == "resuelto" || hilo.Estado == "cerrado" {
_ = models.UpdateTicketEstado(hilo.ID, "abierto")
}
log.Printf("[SoporteWebhook] Respuesta agregada al ticket #%d (%s)", hilo.ID, fromEmail)
continue
}
ticket := &models.ProyectoTicket{
AutorNombre: fromName,
EmailFrom: fromEmail,
Titulo: e.Subject,
Descripcion: contenido,
Estado: "abierto",
Origen: "email",
MessageID: e.MessageID,
}
if cfg.AsignarA != nil {
ticket.AsignadoA = cfg.AsignarA
}
if err := models.CreateProyectoTicket(ticket); err != nil {
log.Printf("[SoporteWebhook] Error creando ticket: %v", err)
continue
}
log.Printf("[SoporteWebhook] Ticket #%d creado desde email (%s): %s", ticket.ID, fromEmail, e.Subject)
// Notificar admin
services.SendSoporteNotifAdmin(ticket)
// Auto-responder (el asunto incluye [Ticket #N] para poder enhebrar la respuesta)
if cfg.ResponderAuto {
services.SendSoporteAutoRespuesta(ticket)
}
} }
return c.Status(200).JSON(fiber.Map{"ok": true}) return c.Status(200).JSON(fiber.Map{"ok": true})
} }
// buscarTicketDeHilo intenta encontrar el ticket al que pertenece una respuesta:
// primero por el token "[Ticket #N]" en el asunto (verificando que sea del mismo
// remitente, para que nadie pueda inyectar mensajes en el ticket de otro
// adivinando el número), y si no hay token, por el último ticket abierto de ese
// remitente cuando el asunto tiene pinta de respuesta (Re:/RE:/Fwd:).
func buscarTicketDeHilo(fromEmail, subject string) *models.ProyectoTicket {
if m := ticketRefRe.FindStringSubmatch(subject); m != nil {
id, _ := strconv.ParseUint(m[1], 10, 32)
if id > 0 {
t, err := models.GetTicketByID(uint(id))
if err == nil && strings.EqualFold(t.EmailFrom, fromEmail) {
return t
}
}
}
lower := strings.ToLower(strings.TrimSpace(subject))
if strings.HasPrefix(lower, "re:") || strings.HasPrefix(lower, "fwd:") || strings.HasPrefix(lower, "fw:") {
if t, err := models.GetUltimoTicketAbiertoPorEmail(fromEmail); err == nil {
return t
}
}
return nil
}
func extractMessageIDFromHeaders(headers string) string { func extractMessageIDFromHeaders(headers string) string {
if headers == "" { if headers == "" {
return "" return ""
@@ -291,26 +199,38 @@ func GetSoporteWebhookConfig(c *fiber.Ctx) error {
if err != nil { if err != nil {
return c.JSON(fiber.Map{"data": nil}) return c.JSON(fiber.Map{"data": nil})
} }
cfg.TieneImapPassword = cfg.ImapPasswordEnc != ""
return c.JSON(fiber.Map{"data": cfg}) return c.JSON(fiber.Map{"data": cfg})
} }
func SaveSoporteWebhookConfig(c *fiber.Ctx) error { func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
type body struct { type body struct {
ID uint `json:"id"` ID uint `json:"id"`
Nombre string `json:"nombre"` Nombre string `json:"nombre"`
Provider string `json:"provider"` Provider string `json:"provider"`
ApiKey string `json:"api_key"` ApiKey string `json:"api_key"`
EmailDestino string `json:"email_destino"` EmailDestino string `json:"email_destino"`
ResponderAuto bool `json:"responder_auto"` ResponderAuto bool `json:"responder_auto"`
MensajeAuto string `json:"mensaje_auto"` MensajeAuto string `json:"mensaje_auto"`
AsignarA *uint `json:"asignar_a"` AsignarA *uint `json:"asignar_a"`
SmtpHost string `json:"smtp_host"` SmtpHost string `json:"smtp_host"`
SmtpPort int `json:"smtp_port"` SmtpPort int `json:"smtp_port"`
SmtpUsername string `json:"smtp_username"` SmtpUsername string `json:"smtp_username"`
SmtpPassword string `json:"smtp_password"` SmtpPassword string `json:"smtp_password"`
SmtpEncryption string `json:"smtp_encryption"` SmtpEncryption string `json:"smtp_encryption"`
SmtpFromAddr string `json:"smtp_from_addr"` SmtpFromAddr string `json:"smtp_from_addr"`
SmtpFromName string `json:"smtp_from_name"` SmtpFromName string `json:"smtp_from_name"`
ImapActivo bool `json:"imap_activo"`
ImapHost string `json:"imap_host"`
ImapPort int `json:"imap_port"`
ImapUsername string `json:"imap_username"`
ImapPassword string `json:"imap_password"`
ImapEncryption string `json:"imap_encryption"`
ImapCarpeta string `json:"imap_carpeta"`
ImapHorasAtras int `json:"imap_horas_atras"`
AgenteBorradorID *uint `json:"agente_borrador_id"`
ClasificarConIA bool `json:"clasificar_con_ia"`
ContextoNegocio string `json:"contexto_negocio"`
} }
var b body var b body
if err := c.BodyParser(&b); err != nil { if err := c.BodyParser(&b); err != nil {
@@ -324,22 +244,60 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
if enc == "" { if enc == "" {
enc = "starttls" enc = "starttls"
} }
imapPort := b.ImapPort
if imapPort == 0 {
imapPort = 993
}
imapEnc := b.ImapEncryption
if imapEnc == "" {
imapEnc = "ssl"
}
carpeta := b.ImapCarpeta
if carpeta == "" {
carpeta = "INBOX"
}
// La contraseña IMAP solo viaja cuando el admin la escribe de nuevo: si el
// formulario la manda vacía, se conserva la que ya estaba guardada.
passEnc := ""
if b.ImapPassword != "" {
enc, err := services.CifrarSecretoUmind(b.ImapPassword)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
passEnc = enc
} else if b.ID > 0 {
if actual, err := models.GetSoporteWebhookActivo(); err == nil && actual != nil && actual.ID == b.ID {
passEnc = actual.ImapPasswordEnc
}
}
cfg := &models.SoporteWebhookConfig{ cfg := &models.SoporteWebhookConfig{
Nombre: b.Nombre, Nombre: b.Nombre,
Provider: b.Provider, Provider: b.Provider,
ApiKey: b.ApiKey, ApiKey: b.ApiKey,
EmailDestino: b.EmailDestino, EmailDestino: b.EmailDestino,
ResponderAuto: b.ResponderAuto, ResponderAuto: b.ResponderAuto,
MensajeAuto: b.MensajeAuto, MensajeAuto: b.MensajeAuto,
AsignarA: b.AsignarA, AsignarA: b.AsignarA,
SmtpHost: b.SmtpHost, SmtpHost: b.SmtpHost,
SmtpPort: port, SmtpPort: port,
SmtpUsername: b.SmtpUsername, SmtpUsername: b.SmtpUsername,
SmtpPassword: b.SmtpPassword, SmtpPassword: b.SmtpPassword,
SmtpEncryption: enc, SmtpEncryption: enc,
SmtpFromAddr: b.SmtpFromAddr, SmtpFromAddr: b.SmtpFromAddr,
SmtpFromName: b.SmtpFromName, SmtpFromName: b.SmtpFromName,
Activo: true, ImapActivo: b.ImapActivo,
ImapHost: b.ImapHost,
ImapPort: imapPort,
ImapUsername: b.ImapUsername,
ImapPasswordEnc: passEnc,
ImapEncryption: imapEnc,
ImapCarpeta: carpeta,
ImapHorasAtras: b.ImapHorasAtras,
ClasificarConIA: b.ClasificarConIA,
ContextoNegocio: b.ContextoNegocio,
AgenteBorradorID: b.AgenteBorradorID,
Activo: true,
} }
cfg.ID = b.ID cfg.ID = b.ID
if err := models.SaveSoporteWebhookConfig(cfg); err != nil { if err := models.SaveSoporteWebhookConfig(cfg); err != nil {
@@ -350,21 +308,94 @@ func SaveSoporteWebhookConfig(c *fiber.Ctx) error {
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
func extractEmail(s string) string { // ProbarImapSoporte valida las credenciales del buzón contra el servidor.
s = strings.TrimSpace(s) // POST /app/api/soporte-webhook/probar-imap
if idx := strings.LastIndex(s, "<"); idx >= 0 { func ProbarImapSoporte(c *fiber.Ctx) error {
s = s[idx+1:] cfg, err := models.GetSoporteWebhookActivo()
if err != nil || cfg == nil {
return c.Status(400).JSON(fiber.Map{"error": "Guardá la configuración antes de probar"})
} }
if idx := strings.LastIndex(s, ">"); idx >= 0 { detalle, err := services.ProbarConexionImap(cfg)
s = s[:idx] if err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
} }
return strings.TrimSpace(s) return c.JSON(fiber.Map{"ok": true, "message": detalle})
} }
func extractName(s string) string { // RevisarBuzonAhora dispara una lectura del buzón sin esperar al cron.
s = strings.TrimSpace(s) // POST /app/api/soporte-webhook/revisar-buzon
if idx := strings.Index(s, "<"); idx >= 0 { func RevisarBuzonAhora(c *fiber.Ctx) error {
return strings.TrimSpace(s[:idx]) encontrados, procesados, err := services.RevisarBuzonSoporteConDetalle()
if err != nil {
if encontrados > 0 {
// Se hizo trabajo igual: decir las dos cosas, no solo el fallo.
return c.Status(200).JSON(fiber.Map{"ok": false, "message": fmt.Sprintf(
"%d correo(s) encontrados, %d convertido(s) en ticket — %s", encontrados, procesados, err.Error())})
}
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
} }
return "" if encontrados == 0 {
return c.JSON(fiber.Map{"ok": true, "message": "Conectó bien, pero no hay correos sin leer en el buzón"})
}
msg := fmt.Sprintf("%d correo(s) sin leer, %d convertido(s) en ticket. El resto ya estaba registrado.", encontrados, procesados)
// Si el filtro descartó algo hay que decirlo acá: un correo de un cliente
// que se comió la IA es exactamente lo que nadie va a ir a buscar al log.
if descartes := services.UltimosCorreosDescartados(); len(descartes) > 0 {
msg += fmt.Sprintf(" El filtro descartó %d: %s", len(descartes), strings.Join(descartes, " | "))
}
if e := services.UltimoErrorAcuse(); e != "" {
msg += " ⚠️ " + e
}
return c.JSON(fiber.Map{"ok": true, "message": msg})
}
// BorradorRespuestaTicket propone una respuesta usando la base de conocimiento.
// No envía nada: el borrador vuelve al cuadro de respuesta para que lo revise
// una persona.
// POST /app/tickets/:ticketID/borrador
func BorradorRespuestaTicket(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("ticketID"), 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
}
ticket, err := models.GetTicketByID(uint(id))
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Ticket no encontrado"})
}
borrador, err := services.RedactarBorradorTicket(ticket)
if err != nil {
return c.Status(422).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"borrador": borrador})
}
// GetAgentesParaBorrador lista los agentes de uMind para elegir de cuál sale la
// documentación de los borradores.
// GET /app/soporte/agentes
func GetAgentesParaBorrador(c *fiber.Ctx) error {
items, err := models.GetTodosLosUmindAgentes()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
salida := make([]fiber.Map, 0, len(items))
for _, a := range items {
salida = append(salida, fiber.Map{"id": a.ID, "nombre": a.Nombre})
}
return c.JSON(salida)
}
// ProbarEnvioSoporte manda un correo de prueba por el mismo camino que el acuse
// automático de los tickets.
// POST /app/soporte/webhook/probar-envio
func ProbarEnvioSoporte(c *fiber.Ctx) error {
var b struct {
Email string `json:"email"`
}
if err := c.BodyParser(&b); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "body inválido"})
}
if err := services.ProbarEnvioSoporte(b.Email); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "message": "Correo de prueba enviado a " + b.Email})
} }
+245 -9
View File
@@ -3,6 +3,7 @@ package controllers
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io"
"math" "math"
"regexp" "regexp"
"strconv" "strconv"
@@ -197,6 +198,9 @@ type umindAgenteReq struct {
MensajeBienvenida string `json:"mensaje_bienvenida"` MensajeBienvenida string `json:"mensaje_bienvenida"`
Color string `json:"color"` Color string `json:"color"`
Activo bool `json:"activo"` Activo bool `json:"activo"`
// PlantillaRubro precarga el conocimiento base de un rubro al crear el
// agente. Vacío = agente en blanco, como era antes.
PlantillaRubro string `json:"plantilla_rubro"`
} }
func CreateUmindAgenteHandler(c *fiber.Ctx) error { func CreateUmindAgenteHandler(c *fiber.Ctx) error {
@@ -232,6 +236,14 @@ func CreateUmindAgenteHandler(c *fiber.Ctx) error {
if err := models.CreateUmindAgente(agente); err != nil { if err := models.CreateUmindAgente(agente); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
// El conocimiento de la plantilla se carga en segundo plano: cada nota
// necesita sus embeddings y el agente ya existe. Hacer esperar el alta por
// esto sería castigar justo al que eligió una plantilla.
if clave := strings.TrimSpace(req.PlantillaRubro); clave != "" {
go services.AplicarPlantillaRubro(agente.ID, clave)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": agente.ID, "site_key": agente.SiteKey}) return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": agente.ID, "site_key": agente.SiteKey})
} }
@@ -297,9 +309,10 @@ func GetUmindDocumentosHandler(c *fiber.Ctx) error {
// segundos/minutos según cuántas páginas tenga el sitio. // segundos/minutos según cuántas páginas tenga el sitio.
func CreateUmindDocumentoHandler(c *fiber.Ctx) error { func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
var req struct { var req struct {
AgenteID uint `json:"agente_id"` AgenteID uint `json:"agente_id"`
URL string `json:"url"` URL string `json:"url"`
MaxPaginas int `json:"max_paginas"` MaxPaginas int `json:"max_paginas"`
AutoActualizar bool `json:"auto_actualizar"`
} }
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
@@ -318,10 +331,12 @@ func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
} }
doc := &models.UmindDocumento{ doc := &models.UmindDocumento{
AgenteID: req.AgenteID, AgenteID: req.AgenteID,
Tipo: "url", Tipo: "url",
Origen: strings.TrimSpace(req.URL), Origen: strings.TrimSpace(req.URL),
Estado: "procesando", Estado: "procesando",
MaxPaginas: req.MaxPaginas,
AutoActualizar: req.AutoActualizar,
} }
if err := models.CreateUmindDocumento(doc); err != nil { if err := models.CreateUmindDocumento(doc); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -356,7 +371,7 @@ func GetUmindSesionesHandler(c *fiber.Ctx) error {
if _, err := accesoAgente(c, uint(agenteID)); err != nil { if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err return err
} }
items, err := models.GetUmindSesiones(uint(agenteID), 50) items, err := models.GetUmindSesionesPanel(uint(agenteID), 50)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
@@ -547,6 +562,7 @@ func GetUmindCanalesHandler(c *fiber.Ctx) error {
"ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo, "ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo,
"webhook_url": webhookURL, "ultimo_error": canal.UltimoError, "webhook_url": webhookURL, "ultimo_error": canal.UltimoError,
"usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes, "usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes,
"usar_archivos_docs": canal.UsarArchivosDocs,
} }
} }
return c.JSON(fiber.Map{"items": out}) return c.JSON(fiber.Map{"items": out})
@@ -559,6 +575,7 @@ type umindCanalReq struct {
Activo bool `json:"activo"` Activo bool `json:"activo"`
UsarWhisperAudio bool `json:"usar_whisper_audio"` UsarWhisperAudio bool `json:"usar_whisper_audio"`
UsarOcrImagenes bool `json:"usar_ocr_imagenes"` UsarOcrImagenes bool `json:"usar_ocr_imagenes"`
UsarArchivosDocs bool `json:"usar_archivos_docs"`
} }
func CreateUmindCanalHandler(c *fiber.Ctx) error { func CreateUmindCanalHandler(c *fiber.Ctx) error {
@@ -594,6 +611,7 @@ func CreateUmindCanalHandler(c *fiber.Ctx) error {
canal := &models.UmindCanal{ canal := &models.UmindCanal{
AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc, AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc,
UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes, UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes,
UsarArchivosDocs: req.UsarArchivosDocs,
} }
if err := models.CreateUmindCanal(canal); err != nil { if err := models.CreateUmindCanal(canal); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -620,7 +638,7 @@ func UpdateUmindCanalHandler(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
} }
updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes} updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes, "usar_archivos_docs": req.UsarArchivosDocs}
if len(req.Credenciales) > 0 { if len(req.Credenciales) > 0 {
enc, err := services.CifrarCredencialesCanal(req.Credenciales) enc, err := services.CifrarCredencialesCanal(req.Credenciales)
if err != nil { if err != nil {
@@ -856,3 +874,221 @@ func slugSimple(s string) string {
} }
return out return out
} }
// ─── Fuentes de conocimiento que no son una URL ─────────────────────────────
// CreateUmindTextoHandler guarda como conocimiento un texto escrito a mano.
// Es la fuente más valiosa y la que no está en ningún lado: horarios, qué no
// hacen, la respuesta a la pregunta que les hacen todos los días.
// POST /umind/documentos/texto
func CreateUmindTextoHandler(c *fiber.Ctx) error {
var req struct {
AgenteID uint `json:"agente_id"`
Titulo string `json:"titulo"`
Contenido string `json:"contenido"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if _, err := accesoAgente(c, req.AgenteID); err != nil {
return err
}
if strings.TrimSpace(req.Contenido) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "escribí algo para que el agente lo aprenda"})
}
titulo := strings.TrimSpace(req.Titulo)
if titulo == "" {
titulo = "Nota"
}
doc := &models.UmindDocumento{
AgenteID: req.AgenteID,
Tipo: "texto",
Origen: titulo,
Contenido: req.Contenido,
Estado: "procesando",
}
if err := models.CreateUmindDocumento(doc); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
go services.IngestarTexto(req.AgenteID, doc.ID, req.Contenido)
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
}
// CreateUmindArchivoHandler carga un archivo como conocimiento. Usa el mismo
// extractor que los adjuntos de los canales: PDF, Word, texto e imágenes.
// POST /umind/documentos/archivo (multipart: agente_id, archivo)
func CreateUmindArchivoHandler(c *fiber.Ctx) error {
agenteID, _ := strconv.ParseUint(c.FormValue("agente_id"), 10, 64)
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
return err
}
archivo, err := c.FormFile("archivo")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subí un archivo"})
}
if archivo.Size > 20<<20 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el archivo supera los 20 MB"})
}
f, err := archivo.Open()
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
}
defer f.Close()
datos, err := io.ReadAll(io.LimitReader(f, 20<<20))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
}
// El texto se extrae acá y no en segundo plano: si el archivo no se puede
// leer, conviene decirlo mientras la persona está mirando la pantalla.
texto, err := services.ExtraerTextoDeArchivo(uint(agenteID), archivo.Filename, datos)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
doc := &models.UmindDocumento{
AgenteID: uint(agenteID),
Tipo: "archivo",
Origen: archivo.Filename,
Contenido: texto,
Estado: "procesando",
}
if err := models.CreateUmindDocumento(doc); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
go services.IngestarTexto(uint(agenteID), doc.ID, texto)
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
}
// ReprocesarUmindDocumentoHandler vuelve a leer una fuente. Para una URL la
// crawlea de nuevo —es la única forma de que el agente deje de contestar los
// precios del año pasado— y para el resto rearma los fragmentos del texto
// guardado.
// POST /umind/documentos/:id/reprocesar
func ReprocesarUmindDocumentoHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoDocumento(c, uint(id)); err != nil {
return err
}
doc, err := models.GetUmindDocumentoByID(uint(id))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "fuente no encontrada"})
}
if doc.Estado == "procesando" {
return c.Status(409).JSON(fiber.Map{"error": "esta fuente ya se está procesando"})
}
services.ReprocesarDocumento(*doc)
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "estado": "procesando"})
}
// ActualizarUmindDocumentoHandler edita el texto de una fuente escrita a mano
// y la vuelve a procesar.
// PUT /umind/documentos/:id
func ActualizarUmindDocumentoHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := accesoDocumento(c, uint(id)); err != nil {
return err
}
doc, err := models.GetUmindDocumentoByID(uint(id))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "fuente no encontrada"})
}
var req struct {
Titulo string `json:"titulo"`
Contenido string `json:"contenido"`
AutoActualizar *bool `json:"auto_actualizar"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
updates := map[string]interface{}{}
if req.AutoActualizar != nil {
updates["auto_actualizar"] = *req.AutoActualizar
}
reprocesar := false
// "texto" son las notas escritas a mano; "archivo" guarda lo que se le
// extrajo al PDF o al Word, y corregir una extracción torcida a mano es
// tan válido como escribir la nota. Una URL no: su contenido se rehace
// crawleando, editarlo se perdería en la próxima actualización.
if (doc.Tipo == "texto" || doc.Tipo == "archivo") && strings.TrimSpace(req.Contenido) != "" {
updates["contenido"] = req.Contenido
if t := strings.TrimSpace(req.Titulo); t != "" {
updates["origen"] = t
}
doc.Contenido = req.Contenido
reprocesar = true
}
if len(updates) > 0 {
if err := models.UpdateUmindDocumento(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
}
if reprocesar {
services.ReprocesarDocumento(*doc)
}
return c.JSON(fiber.Map{"ok": true})
}
// GetPlantillasRubroHandler lista los puntos de partida disponibles para un
// agente nuevo.
// GET /umind/plantillas-rubro
func GetPlantillasRubroHandler(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"items": services.ResumenPlantillasRubro()})
}
// DuplicarUmindAgenteHandler copia un agente configurado a otro tenant (o al
// mismo), con su conocimiento y sus herramientas. Es lo que permite usar un
// agente que ya funciona como plantilla del siguiente.
// POST /umind/agentes/:id/duplicar {tenant_id, nombre}
func DuplicarUmindAgenteHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
// Acceso al origen: sin esto un cliente podría copiarse el agente de otro.
if _, err := accesoAgente(c, uint(id)); err != nil {
return err
}
var req struct {
TenantID uint `json:"tenant_id"`
Nombre string `json:"nombre"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
destino := req.TenantID
if destino == 0 {
origen, err := models.GetUmindAgenteByID(uint(id))
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"})
}
destino = origen.TenantID
}
// Y acceso al destino: copiar es crear, y crear en un tenant ajeno tampoco.
if err := accesoTenant(c, destino); err != nil {
return err
}
if err := verificarCupoAgentes(c, destino); err != nil {
return err
}
copia, err := services.DuplicarAgente(uint(id), destino, strings.TrimSpace(req.Nombre))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"ok": true, "id": copia.ID, "site_key": copia.SiteKey,
"aviso": "El conocimiento se está copiando en segundo plano. Los canales y las claves de las herramientas no se copian: hay que configurarlos en la copia.",
})
}
@@ -0,0 +1,235 @@
package controllers
import (
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
)
// ─── IA propia del cliente ───────────────────────────────────────────────────
//
// Un cliente puede traer su propia cuenta de OpenAI, Anthropic o Gemini en vez
// de consumir la nuestra. La config queda atada a su espacio (TenantID) y solo
// él la ve.
//
// Regla que atraviesa todo este archivo: las configs globales (TenantID nil)
// son del staff y ningún cliente las toca. Puede usarlas —aparecen en su
// selector— pero no editarlas ni borrarlas. Sin esa separación, un cliente
// podría cambiarle la clave de IA a todos los demás.
// aiConfigPropiaReq es lo que manda la pantalla. La clave viaja solo cuando se
// carga o se cambia: en blanco significa "dejá la que ya está".
type aiConfigPropiaReq struct {
TenantID uint `json:"tenant_id"`
Nombre string `json:"nombre"`
Provider string `json:"provider"`
ApiKey string `json:"api_key"`
BaseURL string `json:"base_url"`
ModelName string `json:"model_name"`
}
// proveedoresPermitidos acota lo que se puede cargar. Sin esta lista, un
// provider mal escrito termina en un error del proveedor que no se parece en
// nada a "escribiste mal el nombre".
var proveedoresPermitidos = map[string]bool{
"openai": true, "anthropic": true, "gemini": true,
"groq": true, "qwen": true, "deepseek": true, "ollama": true,
}
func (r aiConfigPropiaReq) validar() error {
if strings.TrimSpace(r.Nombre) == "" {
return fiber.NewError(fiber.StatusBadRequest, "poné un nombre para reconocerla, ej: \"Mi cuenta de OpenAI\"")
}
p := strings.ToLower(strings.TrimSpace(r.Provider))
if !proveedoresPermitidos[p] {
return fiber.NewError(fiber.StatusBadRequest, "proveedor no soportado: elegí OpenAI, Anthropic, Gemini, Groq, Qwen, DeepSeek u Ollama")
}
if strings.TrimSpace(r.ModelName) == "" {
return fiber.NewError(fiber.StatusBadRequest, "falta el modelo, ej: gpt-4o-mini o claude-sonnet-4")
}
// Ollama es el único que corre sin clave, contra un servidor propio.
if p != "ollama" && strings.TrimSpace(r.ApiKey) == "" {
return fiber.NewError(fiber.StatusBadRequest, "falta la clave de API de tu cuenta")
}
if p == "ollama" && strings.TrimSpace(r.BaseURL) == "" {
return fiber.NewError(fiber.StatusBadRequest, "Ollama necesita la URL de tu servidor, ej: https://ollama.tudominio.com/v1")
}
return nil
}
// accesoAiConfigPropia valida que el que llama pueda tocar esa config.
// Devuelve la config ya cargada.
func accesoAiConfigPropia(c *fiber.Ctx, id uint) (*models.AiConfig, error) {
var cfg models.AiConfig
if err := models.GetAiConfigByID(id, &cfg); err != nil {
return nil, errSinAcceso(c)
}
// Una config sin tenant es del staff. Ni siquiera se le confirma al cliente
// que existe.
if cfg.TenantID == nil {
return nil, errSinAcceso(c)
}
if !middlewares.UmindPuedeVerTenant(c, *cfg.TenantID) {
return nil, errSinAcceso(c)
}
return &cfg, nil
}
// ListarAiConfigsPropiasHandler devuelve solo las configs del cliente, con el
// detalle para administrarlas. Las globales quedan afuera: se usan pero no se
// gestionan desde acá.
// GET /umind/ai-propia?tenant_id=N
func ListarAiConfigsPropiasHandler(c *fiber.Ctx) error {
tenantID, _ := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
if err := accesoTenant(c, uint(tenantID)); err != nil {
return err
}
items, err := models.GetAiConfigsPorTenant(uint(tenantID))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]fiber.Map, len(items))
for i, it := range items {
// La clave nunca vuelve, ni al dueño: solo su terminación, que alcanza
// para reconocer cuál cargó.
clave := it.ClaveEnClaro()
pista := "••••"
if len(clave) > 4 {
pista = "••••" + clave[len(clave)-4:]
}
out[i] = fiber.Map{
"ID": it.ID, "nombre": it.Nombre, "provider": it.Provider,
"model_name": it.ModelName, "base_url": it.BaseURL,
"api_key_pista": pista, "is_active": it.IsActive,
}
}
return c.JSON(fiber.Map{"items": out})
}
// CrearAiConfigPropiaHandler carga la cuenta de IA del cliente.
// POST /umind/ai-propia
func CrearAiConfigPropiaHandler(c *fiber.Ctx) error {
var req aiConfigPropiaReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if err := accesoTenant(c, req.TenantID); err != nil {
return err
}
if err := req.validar(); err != nil {
return err
}
tenantID := req.TenantID
cfg := &models.AiConfig{
Nombre: strings.TrimSpace(req.Nombre),
Provider: strings.ToLower(strings.TrimSpace(req.Provider)),
ApiKey: models.CifrarClaveAi(strings.TrimSpace(req.ApiKey)),
BaseURL: strings.TrimSpace(req.BaseURL),
ModelName: strings.TrimSpace(req.ModelName),
IsActive: true,
// El tenant sale del alcance validado, nunca de lo que mande el body
// suelto: si no, bastaría con cambiar el número para colgarle una
// config a otro cliente.
TenantID: &tenantID,
// Sin módulo: es de este espacio y nada más. Ponerle uno la metería en
// el pozo de configs globales que usa el resto del sistema.
Modulo: "",
}
if err := models.CreateAiConfig(cfg); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": cfg.ID})
}
// ActualizarAiConfigPropiaHandler edita una config del cliente.
// PUT /umind/ai-propia/:id
func ActualizarAiConfigPropiaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if _, err := accesoAiConfigPropia(c, uint(id)); err != nil {
return err
}
var req aiConfigPropiaReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
// En edición la clave puede venir vacía: es "no la cambies".
if strings.TrimSpace(req.ApiKey) == "" {
req.ApiKey = "sin-cambio"
}
if err := req.validar(); err != nil {
return err
}
updates := map[string]interface{}{
"nombre": strings.TrimSpace(req.Nombre),
"provider": strings.ToLower(strings.TrimSpace(req.Provider)),
"base_url": strings.TrimSpace(req.BaseURL),
"model_name": strings.TrimSpace(req.ModelName),
}
if req.ApiKey != "sin-cambio" {
updates["api_key"] = models.CifrarClaveAi(strings.TrimSpace(req.ApiKey))
}
// tenant_id no se toca nunca: mover una config de espacio es regalarle a
// otro cliente una clave que no es suya.
if err := models.UpdateAiConfig(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// BorrarAiConfigPropiaHandler elimina la cuenta de IA del cliente.
// DELETE /umind/ai-propia/:id
func BorrarAiConfigPropiaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if _, err := accesoAiConfigPropia(c, uint(id)); err != nil {
return err
}
// Los agentes que la estaban usando quedan apuntando a una config que ya
// no existe y caerían al fallback global sin avisar. Mejor frenar y que la
// persona decida.
usando, err := models.ContarAgentesConAiConfig(uint(id))
if err == nil && usando > 0 {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": "hay agentes usando esta cuenta de IA: cambiales el modelo antes de borrarla",
})
}
if err := models.DeleteAiConfig(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// ProbarAiConfigPropiaHandler manda una consulta real a la cuenta del cliente.
// Es la diferencia entre enterarse ahora o cuando un cliente real no recibe
// respuesta.
// POST /umind/ai-propia/:id/probar
func ProbarAiConfigPropiaHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
cfg, err := accesoAiConfigPropia(c, uint(id))
if err != nil {
return err
}
respuesta, err := services.ProbarAiConfig(cfg)
if err != nil {
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "respuesta": respuesta})
}
+55
View File
@@ -0,0 +1,55 @@
package controllers
import "testing"
// La validación es lo único que separa "cargué mi cuenta y anda" de un error
// del proveedor que nadie sabe leer.
func TestValidacionDeCuentaPropia(t *testing.T) {
casos := []struct {
nombre string
req aiConfigPropiaReq
valida bool
}{
{"completa", aiConfigPropiaReq{Nombre: "Mi OpenAI", Provider: "openai", ApiKey: "sk-x", ModelName: "gpt-4o-mini"}, true},
{"sin nombre", aiConfigPropiaReq{Provider: "openai", ApiKey: "sk-x", ModelName: "gpt-4o-mini"}, false},
{"sin modelo", aiConfigPropiaReq{Nombre: "X", Provider: "openai", ApiKey: "sk-x"}, false},
{"sin clave", aiConfigPropiaReq{Nombre: "X", Provider: "openai", ModelName: "gpt-4o-mini"}, false},
// Un proveedor inventado se manda igual y falla recién contra la API,
// con un mensaje que no dice "escribiste mal el nombre".
{"proveedor inventado", aiConfigPropiaReq{Nombre: "X", Provider: "chatgpt-5", ApiKey: "k", ModelName: "m"}, false},
// Ollama corre contra un servidor propio: no lleva clave, pero sin URL
// no hay a dónde ir.
{"ollama con url y sin clave", aiConfigPropiaReq{Nombre: "X", Provider: "ollama", ModelName: "llama3.1", BaseURL: "https://o.x/v1"}, true},
{"ollama sin url", aiConfigPropiaReq{Nombre: "X", Provider: "ollama", ModelName: "llama3.1"}, false},
{"mayúsculas en el proveedor", aiConfigPropiaReq{Nombre: "X", Provider: "OpenAI", ApiKey: "k", ModelName: "m"}, true},
}
for _, c := range casos {
err := c.req.validar()
if c.valida && err != nil {
t.Errorf("%s: debería pasar, dio: %v", c.nombre, err)
}
if !c.valida && err == nil {
t.Errorf("%s: debería rechazarse y pasó", c.nombre)
}
}
}
// Las configs globales son del staff y las comparten todos los clientes. Que un
// cliente pudiera editarlas sería cambiarle el proveedor de IA a todos los
// demás; que pudiera borrarlas, dejarlos sin servicio.
func TestLasConfigsGlobalesNoSonDelCliente(t *testing.T) {
// accesoAiConfigPropia corta cuando TenantID es nil, antes de mirar
// permisos. Se comprueba la condición porque el handler necesita base.
var tenantNil *uint
if tenantNil != nil {
t.Fatal("una config global tiene TenantID nil")
}
esGlobal := tenantNil == nil
if !esGlobal {
t.Error("una config sin tenant tiene que tratarse como global e intocable")
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ func AuthServicioPago(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": "Token inválido o servicio inactivo"}) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": "Token inválido o servicio inactivo"})
} }
if !servicio.IPPermitida(c.IP()) { if !servicio.IPPermitida(IPDelCliente(c)) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": true, "message": "IP no autorizada para este servicio"}) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": true, "message": "IP no autorizada para este servicio"})
} }
+9 -3
View File
@@ -38,11 +38,17 @@ func AdminApiAuth() fiber.Handler {
if err != nil { if err != nil {
return c.Status(401).JSON(fiber.Map{"error": "unauthorized"}) return c.Status(401).JSON(fiber.Map{"error": "unauthorized"})
} }
if !key.IPValida(c.IP()) { if ip := IPDelCliente(c); !key.IPValida(ip) {
return c.Status(403).JSON(fiber.Map{"error": "IP no autorizada para esta API key"}) // Decir qué IP se vio: sin eso, del otro lado no hay forma de saber
// qué cargar en la llave y se prueba a ciegas.
return c.Status(403).JSON(fiber.Map{
"error": "IP no autorizada para esta API key",
"ip_vista": ip,
"ip_permitida": key.IPPermitida,
})
} }
go models.RegistrarUsoApiKey(key.ID, c.IP()) go models.RegistrarUsoApiKey(key.ID, IPDelCliente(c))
c.Locals("api_key", key) c.Locals("api_key", key)
return c.Next() return c.Next()
} }

Some files were not shown because too many files have changed in this diff Show More