Commit Graph
486 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 5 ce5956878d fix(studio): uMind era inusable en móvil
El SPA no tenía ningún tratamiento responsive: un sidebar fijo de 256px
siempre visible. En un teléfono de 375px eso deja 119px para el contenido.

Ahora en móvil el sidebar sale de flujo y entra deslizándose desde la
izquierda, con botón en el header y fondo oscuro para cerrarlo. Se cierra
solo al elegir un tenant: dejarlo abierto tapa justo la pantalla a la que
se acaba de entrar. En escritorio (md+) no cambia nada — sigue fijo y sin
botón.

El estado vive en lib/ui.js porque lo tocan dos componentes que no son
padre/hijo: el botón del header y el propio sidebar.

También se achica el padding del contenido en pantallas chicas y las tabs
del agente pasan a scrollear en su propia caja en vez de desbordar.

Medido a 492px de ancho: scrollWidth igual a innerWidth y ningún elemento
sobresaliendo. Escritorio verificado sin regresión.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 18:16:34 -05:00
Lizandro GuarnizoandClaude Sonnet 5 e2440b04f1 feat(telegram): el bot resuelve clientes por nombre y mide sus propios fallos
Dos de las cuatro mejoras de la auditoría del agente.

1. Las herramientas aceptan cliente_nombre además de cliente_id.

   De 27 parámetros que eran IDs, 10 eran de cliente. Para cada acción el
   modelo tenía que encadenar: listar_clientes → leer la respuesta →
   encontrar la fila → extraer el número → recién ahí llamar a la
   herramienta. Cuatro pasos, y basta que falle uno para que no se guarde
   nada — es la explicación más probable de la factura que nunca se creó,
   porque ese cliente podía no venir en la primera página del listado.

   Ahora el modelo pasa el nombre y el servidor lo resuelve, con búsqueda
   completa y no paginada. Si hay varios candidatos devuelve la lista para
   que el modelo pregunte cuál, en vez de elegir uno: cargarle una factura
   a la empresa equivocada es peor que preguntar. Una coincidencia exacta
   gana sobre las parciales, así "Metropolitana" no queda ambiguo solo
   porque existe "Metropolitana Norte".

2. Contadores de uso y fallo por herramienta, expuestos como
   estado_herramientas.

   Cada problema se venía diagnosticando de a un caso, reconstruyendo qué
   pasó después de que el usuario avisara. Ahora se puede preguntar
   directamente qué viene fallando y ver si es una herramienta puntual o el
   modelo eligiendo mal. En memoria a propósito: alcanza para responder eso
   y no agrega una tabla.

Quedan las otras dos: adelgazar el prompt de 5,4 KB moviendo los manuales
por dominio a las descripciones de las herramientas, y comparar modelos con
un mismo caso. La primera conviene hacerla con el sistema estable, porque
toca cómo decide el modelo en todos los flujos a la vez.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:24:06 -05:00
Lizandro GuarnizoandClaude Sonnet 5 d0c9ed5656 fix(telegram): si una herramienta falla, el bot lo dice aunque el modelo no
Confirmado que la factura nunca se creó: la lista de /app/facturas no
filtra nada que pudiera esconderla. La herramienta falló y el modelo
informó "FACTURA GUARDADA EXITOSAMENTE" igual.

El commit anterior le prohíbe eso por prompt, pero un prompt es una
sugerencia. Esto es la garantía: el código junta los errores que
devolvieron las herramientas durante la conversación y los agrega a la
respuesta. Si algo no se completó, se ve, diga lo que diga el modelo.

No reemplaza al prompt — el modelo sigue debiendo explicar el fallo con
sus palabras. Es la red por debajo, para que un "guardado exitosamente"
sobre algo que no se guardó no pueda pasar desapercibido otra vez.

Sigue sin saberse por qué falló aquella vez, porque hasta hoy el resultado
de las herramientas no se registraba. Con el log y este aviso, el próximo
intento lo va a decir en el momento.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:12:23 -05:00
Lizandro GuarnizoandClaude Sonnet 5 560dc261e9 feat(telegram): el bot puede corregir facturas y registrar cobros
Auditoría de qué expone /api/v2 contra qué puede hacer el bot. El bot no
llama a la API: usa su propio registro de herramientas, así que la API
puede crecer y el bot queda atrás sin que nada lo señale.

De 46 herramientas contra ~300 endpoints de v2, el hueco más caro estaba
en el flujo que se estuvo usando hoy:

- Se podían crear cuentas por cobrar y por pagar, pero no marcarlas
  pagadas — y marcar pagado es justamente lo que genera la transacción en
  contabilidad. O sea que el bot podía registrar deuda pero nunca cerrarla.
- No había forma de corregir una factura mal cargada, que es exactamente lo
  que hacía falta después del bug del monto en cero.

Se agregan actualizar_factura, marcar_cuenta_cobro_pagada y
marcar_cuenta_pagar_pagada. actualizar_factura solo pisa los campos que
vinieron, así que corregir el monto no borra el número ni el concepto.

Quedan sin herramientas áreas enteras que v2 sí cubre (hostinger,
cloudflare, websms, portal-usuarios, uMind, api-keys, saas, plantillas).
No se agregan a ciegas: cada herramienta ocupa lugar en el prompt y
compite por la atención del modelo, así que conviene sumarlas cuando haya
un uso real detrás.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:08:24 -05:00
Lizandro GuarnizoandClaude Sonnet 5 d5fbdf6d97 feat(telegram): registrar una factura de venta sin archivo adjunto
Por qué no aparecía la factura: la única herramienta de venta del bot era
adjuntar_factura, y exige un documento pendiente en el chat. Al dictarle
los datos por texto el modelo no tenía con qué guardar — y como el prompt
tampoco se lo prohibía, informó "guardada exitosamente" sobre algo que
nunca se creó.

/api/v2/facturas ya permitía crearlas, pero el bot no llama a la API REST:
usa su propio registro de herramientas, así que solo puede hacer lo que ese
registro expone. El hueco estaba ahí, no en la API.

crear_factura toma cliente_id y monto (obligatorios), número, concepto y
fechas. Devuelve el monto realmente guardado y el id, que es lo que el
prompt le exige informar en vez de repetir lo que dijo el usuario.

Las descripciones de las dos herramientas se remiten entre sí para que el
modelo no elija la equivocada: con documento adjunto va adjuntar_factura,
con datos dictados va crear_factura. El test verifica esa distinción,
porque elegir mal reproduce exactamente el fallo original.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:04:07 -05:00
Lizandro GuarnizoandClaude Sonnet 5 e9cb8107eb fix(telegram): el monto de una factura se guardaba en cero
El bot informó "FACTURA GUARDADA EXITOSAMENTE" con $290.000 y el registro
no aparecía. Revisando el camino salieron tres cosas, y las tres explican
por qué el mensaje del bot no se podía tomar como prueba de nada.

1. Los montos se leían con getInt, que solo acepta números JSON. Si el
   modelo manda el monto como texto ("290000", "$290.000") devuelve el
   valor por defecto: 0. Y con decimales los truncaba. La factura quedaba
   creada en cero mientras el bot informaba el monto correcto, porque el
   bot repite lo que dijo el usuario, no lo que se guardó. Ahora hay un
   getFloat que acepta número o texto y limpia $ , y espacios.

2. Se registraba la llamada a la herramienta pero no su resultado, así que
   no había forma de saber si guardó o devolvió error. Ahora se registra
   también el resultado: es la única fuente confiable de qué pasó, porque
   el mensaje del bot lo escribe el modelo.

3. El prompt no prohibía informar éxito sin haberlo verificado. Ahora se
   le exige no decir que guardó algo si la herramienta devolvió error, y
   confirmar con los valores que devolvió la herramienta y no con los que
   dijo el usuario.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:01:02 -05:00
Lizandro GuarnizoandClaude Sonnet 5 0100986a46 fix(menu): el administrador no veía los módulos a los que sí puede entrar
El módulo Facturas tiene ruta, vista y seed, pero no aparecía en el menú.
No faltaba: MenuMiddleware arma el menú solo con los submódulos del rol del
usuario, y esa parte corre ANTES del bypass de IsAdmin.

O sea que un administrador entra a cualquier ruta escribiéndola, pero solo
ve en el menú lo que su rol tenga asignado. Un módulo completo puede
parecer inexistente por eso, y no hay nada en pantalla que lo explique.

Ahora, si el usuario es administrador, el menú se arma con todos los
submódulos: coincide con el acceso que ya tiene. Para el resto no cambia
nada — siguen viendo solo lo suyo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:56:21 -05:00
Lizandro GuarnizoandClaude Sonnet 5 351454c018 fix(contabilidad): "sin datos" ya no se ve igual que "se rompió"
En /app/contabilidad/cuentas-cobro la petición respondía bien pero la tabla
quedaba en blanco, sin nada que dijera qué había pasado.

Probando la vista real con axios simulado quedó claro que el problema no
era el renderizado: con datos pinta bien, y que falle /clientes/select
tampoco la rompe. Lo que faltaba era distinguir el caso vacío.

Dos arreglos, uno en cada punta:

- Las 8 consultas de contabilidad declaraban `var items []T`, que sin filas
  se serializa como null y no como []. La vista hacía x-for sobre null y no
  pintaba nada; peor, con null se colaba además una fila basura.

- Ninguna de las 5 tablas tenía estado vacío. Ahora dicen "No hay ...
  registradas", así que cero filas se lee como cero filas.

El x-for además queda blindado con (items || []) para no depender de que
todos los endpoints devuelvan siempre una lista.

Verificado con la vista real en tres escenarios: con datos pinta la fila,
vacío y null muestran el mensaje y ninguna fila de más.

Si la pantalla sigue sin traer registros después de esto, ya no es la
vista: es que la consulta no encuentra filas, y el mensaje lo va a decir.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:47:24 -05:00
Lizandro GuarnizoandClaude Sonnet 5 c6266d9f69 feat(menu): buscador en la barra lateral
Con ~40 submódulos repartidos en una docena de módulos, encontrar una
pantalla era ir abriendo y cerrando secciones.

El buscador filtra por título de submódulo y también por nombre de módulo
(quien escribe "integraciones" espera ver todo lo que cuelga de ahí). La
comparación normaliza acentos y mayúsculas: nadie escribe "Documentación"
con tilde cuando busca. Los módulos sin coincidencias se ocultan, Enter
entra al primer resultado y Esc limpia.

Dos decisiones que salieron de verlo fallar:

- La lógica va en un <script> y no dentro de x-data="{...}". El atributo se
  corta con la primera comilla doble, así que un comentario con comillas
  rompía la barra entera de forma silenciosa.

- El filtrado es una pasada imperativa sobre el DOM en vez de una condición
  por elemento. Las condiciones dependían de una lista armada al iniciar y
  no se re-evaluaban al escribir: el menú se filtraba a medias. Recorrer
  unas decenas de nodos por tecleo no se nota y no deja lugar a esas
  sutilezas.

Tampoco se interpola el título dentro de las expresiones: va en data-, que
Go sí escapa bien. Verificado con títulos que traen comillas y ampersand,
que con la versión anterior rompían el binding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:43:11 -05:00
Lizandro GuarnizoandClaude Sonnet 5 55f28f5c06 fix(permisos): los endpoints de datos ya no se abren sin el módulo asignado
Primera tanda del hueco reportado en el commit anterior: el permiso tapaba
la página HTML pero no los datos. Cualquier usuario con sesión podía pedir
GET /app/loadusers o DELETE /app/doc/paginas/:id escribiendo la URL, sin
tener el submódulo asignado.

No alcanzaba con poner MenuMiddleware en esas rutas: compara la ruta pedida
contra los submódulos del rol, y el endpoint de datos vive en otra ruta que
la pantalla (/app/loadusers alimenta /app/users). Compararla consigo misma
nunca coincidiría y dejaría afuera hasta a quien sí tiene el permiso.

RequiereModulo("/app/users") declara en la ruta a qué pantalla pertenece el
endpoint, y valida ese submódulo. El administrador sigue entrando a todo,
igual que en MenuMiddleware.

Esta tanda cubre lo que más duele: identidad y permisos (usuarios, roles,
módulos, submódulos), donde una fuga es escalada de privilegios, y
Documentación, que es el módulo que disparó la auditoría.

Quedan las otras tandas (contabilidad, facturas, clientes, servidores...).
Se hace por partes a propósito: cerrar 400 rutas de una puede dejar gente
afuera de pantallas que hoy usa.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:25:31 -05:00
Lizandro GuarnizoandClaude Sonnet 5 6942c04d27 fix(usuarios): un usuario con tipo inesperado desaparecía de todas las listas
El administrador no aparecía en /app/users.

La lista de usuarios del sistema filtraba con una lista blanca
(tipo_usuario 'sistema', '' o NULL) y la pantalla de usuarios 'gas' es la
complementaria. Un usuario con cualquier otro valor —o con uno viejo de
antes de que existiera el campo— no salía en NINGUNA de las dos: quedaba
invisible en todo el panel aunque pudiera entrar y fuera administrador.

Ahora se excluye 'gas' en vez de exigir 'sistema', así todo usuario cae en
exactamente una de las dos listas.

Además la búsqueda usaba LIKE, que en Postgres distingue mayúsculas:
buscar "lizandro" no encontraba a "Lizandro", así que el atajo obvio para
comprobarlo tampoco funcionaba. Pasa a ILIKE y también busca por email,
que es lo que la mayoría escribe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:13:03 -05:00
Lizandro GuarnizoandClaude Sonnet 5 105ab44759 fix(permisos): compara la ruta completa y avisa de los menús que dan 404
Auditoría del sistema de permisos a partir del 404 en /app/doc/paginas.

1. La comparación de permisos usaba solo el último segmento de la URL
   (lo que va después del último '/'). Rompía en las dos direcciones: con
   permiso sobre /app/doc/categorias se entraba a cualquier otra ruta
   terminada en "categorias", y a la vez el permiso parecía no aplicarse
   donde sí correspondía porque el segmento coincidía por casualidad.
   Ahora se compara la ruta completa, cortando en el separador para que
   /app/doc/paginas no habilite /app/doc/paginas-privadas.

2. El submódulo de Documentación nunca se sembró, aunque las rutas y las
   vistas existen desde mayo. Había que crear el ítem del menú a mano, y
   una URL mal tipeada ahí se ve exactamente igual que un permiso mal
   asignado: un 404. Ahora se siembra con la URL correcta.

3. El submódulo "Statuspage" apuntaba a /app/statuspage, que no existe en
   ninguna parte — la página real es la pública /status. Se corrige el
   seed y también el registro ya creado en la base.

4. Chequeo en el arranque que recorre los submódulos de la base y avisa
   cuáles apuntan a una URL sin ruta. Cubre los creados a mano, que es
   justo donde el compilador y los tests no llegan.

5. Test que cruza las URLs sembradas contra las rutas registradas: fue el
   que encontró lo de statuspage.

Queda pendiente y es más grande: de 454 rutas protegidas solo 54 pasan por
MenuMiddleware. El permiso gatea la página HTML pero no los endpoints de
datos — cualquier usuario autenticado puede llamar a
GET /app/doc/loadpaginas o DELETE /app/doc/paginas/:id sin tener el
submódulo asignado. Se reporta antes de tocarlo porque cerrarlo de golpe
puede dejar gente afuera.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 09:21:44 -05:00
Lizandro GuarnizoandClaude Sonnet 5 ad3458bb71 fix(studio): cambiar de tenant o de agente no recargaba la pantalla
Al navegar de /tenants/2 a /tenants/3, vue-router reusa la misma instancia
del componente porque es la misma ruta con otro parámetro. onMounted no
vuelve a dispararse, así que la vista seguía mostrando los agentes del
tenant anterior.

Las tres vistas tenían el mismo problema: TenantAgentes, AgenteDetail y
Uso. Ahora reaccionan al parámetro (watch con immediate) en vez de al
montaje.

En AgenteDetail se limpia el estado antes de pedir los datos nuevos: si no,
durante la carga se ven los documentos, canales y conversaciones del agente
anterior bajo el nombre del nuevo, que es peor que una pantalla vacía.

Verificado navegando de verdad entre dos tenants con agentes distintos: la
lista pasa de "Ventas T2" a "Soporte T3 / Cobros T3".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 08:01:37 -05:00
Lizandro GuarnizoandClaude Sonnet 5 a25329c7b1 fix(umind): libera las columnas huérfanas que dejó el refactor multi-agente
Error en producción al crear un tenant:

    null value in column "site_key" of relation "umind_tenants"
    violates not-null constraint (SQLSTATE 23502)

Es un bug que introduje yo. Al pasar uMind a multi-agente saqué SiteKey de
UmindTenant y renombré TenantID→AgenteID en seis tablas. GORM agrega
columnas pero nunca las borra ni les cambia las restricciones, así que las
viejas quedaron en la base CON su NOT NULL original — y el INSERT nuevo ya
no las incluye.

Al mirarlo, el alcance era mayor que el error reportado: no es solo
site_key. Las seis tablas renombradas tienen su tenant_id huérfano también
NOT NULL, así que fallaba insertar documentos, chunks, mensajes, tools,
canales y conexiones. En la práctica uMind quedaba inutilizable después de
desplegar el refactor: ni crear un tenant, ni ingestar conocimiento, ni
guardar un mensaje del chat.

La migración corre en cada arranque, antes de MigrarUmindAgentes, y
consulta information_schema para no intentar el ALTER a ciegas en una
instalación nueva donde la columna no existe.

No se hace DROP COLUMN a propósito: los datos viejos quedan por si hay que
reconciliar algo. Solo se libera la restricción.

El test compara los nombres de tabla contra el TableName() real de cada
modelo. Un typo ahí haría que la migración no encuentre la columna y siga
de largo: el bug seguiría vivo y el arranque se vería sano.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 07:45:17 -05:00
Lizandro GuarnizoandClaude Sonnet 5 2e3be1ca92 feat(api): monta los endpoints VCard en /api/v2 con scope propio
La integración VCard vivía solo en /api/v1, que autentica por cookie de
sesión: obliga a manejar cookie jar, no permite allowlist por IP y no se
puede revocar sin tocar la contraseña del usuario (ver
docs/api-v1-contrato.md, punto 7).

Ahora los mismos controladores están también bajo /api/v2/vcard/* con
Bearer + IP + scope, que es lo que administra la pantalla /app/api-keys.
El scope "vcard" acota la llave a estos 12 endpoints: sin él, esa
integración tendría acceso a los otros ~300 de v2.

/api/v1 se mantiene intacto — esto es un camino nuevo, no un reemplazo
forzado, así que lo que ya está instalado sigue andando mientras migran.

Se agregan dos chequeos porque el compilador no ve ninguno de los dos
errores: que las 12 rutas queden registradas con su método y path (un
typo se descubriría recién con un 404 del lado del integrador), y que
todas figuren en el spec de /api/v2, que se mantiene a mano y se
desincroniza en silencio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 02:06:49 -05:00
Lizandro GuarnizoandClaude Sonnet 5 ff4237eced docs: contrato real de la API v1 para el equipo integrador
Responde las 10 preguntas que mandaron sobre admin.u-site.app leyendo el
código del servidor en vez de inferirlas desde el cliente, con cita de
archivo y línea en cada una.

Lo que sale de ahí:

- expires_in sí es timestamp absoluto (su lectura era correcta), pero el
  token se emite con 90 años de vigencia porque Login no pasa vencimiento.
- La API v1 NO acepta Bearer: autentica solo por la cookie
  Verify-Rest-Token, así que el placeholder "..." sin completar deja los 12
  endpoints en 401. Es la causa más probable de que la integración nunca
  haya autenticado por sí misma.
- Hay DOS textos de error 401 ("Token not found" y "Invalid Attempt"), y su
  reintento solo cubre el segundo.
- El envoltorio doble-codificado de dLocal es real, nadie lo adivinó mal.
- generate-qr devuelve imagen binaria, no JSON.

Incluye los pendientes de nuestro lado (vigencia del token, Secure=false en
la cookie, auth por cookie en una API máquina-a-máquina) y la recomendación
de usar /api/v1/pagos-externos, que ya usa Bearer + IP, para integraciones
nuevas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 02:02:59 -05:00
Lizandro GuarnizoandClaude Sonnet 5 8ef49c5169 feat: bucles de crecimiento y activación de uMind
Cuatro cosas que ya estaban a medio construir y no se usaban como canal.

1. El badge del widget ahora es un enlace con UTM. Cada cliente ya te
   estaba dando exposición en su sitio y no se capitalizaba. El host sale
   de la URL del propio script, así funciona igual en cualquier entorno.

2. Reporte del mes por agente en Excel: conversaciones atendidas, con qué
   las abrió el visitante, y el consumo desglosado. Es lo que el cliente
   necesita para justificar el gasto puertas adentro — un panel al que hay
   que entrar no sirve para eso, un archivo que se reenvía sí. Reusa el
   generador de xlsx del cronograma.

3. Aviso automático de agentes sin base de conocimiento (cron diario). Un
   agente sin fuentes responde de memoria e inventa datos, el cliente
   concluye que el producto no sirve y se va. Es el punto donde más gente
   se cae y se detecta solo. Se avisa UNA vez por agente, usando el log de
   auditoría como registro de envío para no necesitar tabla nueva ni
   convertir el recordatorio en spam.

4. Tarjeta de uMind en el dashboard del portal: atajo si el cliente ya lo
   tiene, oferta si no. Es el punto de contacto más barato que hay — ya
   entró, ya confía, y el cobro se suma a la factura que ya recibe. El
   mensaje lidera con notas de voz y fotos, que es lo más difícil de
   copiar de lo que tenemos.

La consulta de conversaciones agrupa en SQL en vez de traerse el historial
entero para agrupar en Go.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 22:40:28 -05:00
Lizandro GuarnizoandClaude Sonnet 5 cfa91a5c09 fix(portal): el avance del dashboard mostraba siempre el valor anterior
Auditoría de /portal/dashboard. Cuatro problemas, todos en el mismo bucle:

1. El avance se veía un render atrasado. ActualizarProgresoProyecto corría
   DESPUÉS de cargar los proyectos: escribía el valor nuevo en la base pero
   los structs ya cargados seguían con el viejo, que es lo que se renderiza.
   El usuario veía el cálculo de la visita anterior.

2. N+1 con escrituras en un GET: dos COUNT y un UPDATE por proyecto. Con 10
   proyectos, 30 consultas y 10 escrituras por cada carga del dashboard —
   incluyendo las de cualquier bot que pase. Ahora es UNA consulta agrupada
   y ninguna escritura; los caminos que tocan una fase ya mantienen la
   columna al día, así que recalcular en el GET no aportaba nada.

3. Orden aleatorio de los grupos: se recorría un map de Go, así que un
   partner veía sus clientes en distinto orden en cada recarga.

4. isPartner se decidía con u.Rol mientras el alcance se decidía con
   Role.EsPortalPartner. Desincronizados, un usuario veía proyectos de
   varios clientes sin agrupar, o la vista agrupada vacía. Ahora hay una
   sola definición (PortalUser.EsPartner) con test de los dos sentidos.

Además, el error de carga se descartaba con `_` y el usuario terminaba
viendo "no tenés proyectos", indistinguible de una caída de la base.

Sin hallazgos de seguridad: el chequeo de acceso por cliente está en todas
las rutas, la sesión revalida Activo en cada request, y las plantillas no
usan x-html ni template.HTML, así que Go escapa todo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 22:26:44 -05:00
Lizandro GuarnizoandClaude Sonnet 5 62c644b815 feat(portal): descarga del cronograma del proyecto en Excel
El cliente ya veía el roadmap en pantalla pero no tenía cómo pasárselo a
alguien que no entra al portal.

El .xlsx se genera con archive/zip + encoding/xml de la stdlib en vez de
sumar una dependencia de Excel para una sola pantalla. Un CSV renombrado
no servía: en Excel en español el separador y los acentos se rompen, y
esto es un archivo que el cliente le entrega a un tercero.

Detalles que hacen que Excel lo acepte y se lea bien: inline strings (sin
tabla sharedStrings, así ningún índice puede apuntar a la cadena
equivocada), encabezado en negrita y congelado, anchos de columna según
el contenido, y nombre de hoja saneado (Excel rechaza el archivo si pasa
de 31 caracteres o trae : \ / ? * [ ]).

El endpoint repite el chequeo de acceso de PortalProyecto: sin eso,
cualquiera con sesión de portal se bajaba el cronograma de otro cliente
adivinando el slug.

Verificado abriendo el archivo generado con openpyxl: 6 partes, CRC OK,
XML bien formado en todas, acentos y CJK intactos, negrita y panel
congelado donde corresponde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 22:12:45 -05:00
Lizandro GuarnizoandClaude Sonnet 5 5182c45d11 feat(studio): tarjetas de agente con señales de estado
La lista de agentes era un directorio: nombre y activo/inactivo. No decía
si el agente estaba realmente listo para atender.

- Tarjetas en grid con el color de marca del agente (el mismo que ve el
  visitante en el widget) y sus iniciales.
- Tres datos por agente: conversaciones de los últimos 7 días, fuentes de
  conocimiento y canales activos. Salen de tres consultas agrupadas, no de
  tres por agente: el N+1 en un listado es el que después no se saca.
- Badge de estado accionable. "sin conocimiento" cuando el agente está
  activo pero no tiene ninguna fuente — ese agente responde de memoria e
  inventa datos, que es exactamente el problema de la URL falsa, y hasta
  ahora no se veía hasta que un cliente recibía la respuesta inventada.
- Esqueleto de carga: la pantalla hace 3 llamadas y quedaba en blanco el
  tiempo suficiente como para parecer rota.

Arregla además el fondo en modo oscuro: index.html traía
<body class="bg-gray-50">, y esa utilidad le ganaba a la regla de body de
la capa base, así que el área principal quedaba clara con el tema oscuro
puesto (medido: --fondo resolvía a 9 13 22 pero el body pintaba 249 250 251).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 22:09:32 -05:00
Lizandro GuarnizoandClaude Sonnet 5 45f773fb62 feat(studio): muestra el cupo de agentes del plan en pantalla
Se podían crear 2 agentes con un plan de 1 sin que nada lo explicara. El
límite del backend sí funciona, pero solo aplica si el tenant tiene un
plan asignado — y hasta 115f167 los campos Cliente y Plan no aparecían en
el formulario del tenant, así que en la práctica quedaban todos sin plan
y sin límite.

El problema de fondo era que ese estado no se veía por ningún lado: un
tenant sin plan se comportaba igual que un límite roto.

- La lista de agentes devuelve también el plan y cuántos hay usados.
- La pantalla muestra "Básico · 1 de 1", "Pro · ilimitado" o "sin plan ·
  sin límite", y en este último caso explica que hay que asignarle uno.
- El botón + Nuevo agente se deshabilita al llegar al tope, en vez de
  dejar que el límite aparezca recién como un error al guardar.

El backend sigue siendo la autoridad: esto es feedback, no la validación.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 22:05:32 -05:00
Lizandro GuarnizoandClaude Sonnet 5 5c931c9400 fix(ui): los modales largos ya no dejan los botones fuera de pantalla
En pantallas bajas (o con formularios largos como el de portal-usuarios)
el panel del modal se salía de la ventana y no había forma de llegar a
Guardar ni a Cancelar.

La regla va en el layout y no en cada vista: son ~214 modales repartidos
en las plantillas y arreglarlos uno por uno garantizaba olvidarse varios.
El selector toma solo los overlays centrados y deja afuera los 4 paneles
laterales, que ya manejan su propio scroll.

El alto se mide en % y no en vh/dvh: el overlay es inset-0, o sea que ya
es exactamente la caja visible. Medido en Edge headless a 600/900/700/420
de alto: el panel queda siempre en ventana-2rem, el tope nunca se recorta,
y Guardar se alcanza scrolleando. Con ventana alta el panel conserva su
alto natural y no scrollea.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:52:54 -05:00
Lizandro GuarnizoandClaude Sonnet 5 115f1675ad fix(studio): los campos Cliente y Plan no aparecían al crear un tenant
/app/api/clientes/select devuelve el array pelado y /app/umind-planes/list
lo envuelve en {items}. El código leía solo .registros/.items, así que con
el array quedaba vacío — y el bloque estaba detrás de v-if="clientes.length",
o sea que Cliente y Plan simplemente no se veían y no había forma de
asignarlos.

- lista() acepta las dos formas (verificado contra ambas y contra
  vacío/null/undefined).
- Se cargan con allSettled: que falle uno ya no vacía el otro.
- El bloque se muestra siempre para el staff, y cada select dice a dónde ir
  si su lista está vacía. Antes un fallo de red se veía exactamente igual
  que "no hay nada configurado", que es lo que enmascaró este bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:49:32 -05:00
Lizandro GuarnizoandClaude Sonnet 5 693ff608b1 feat(portal): enlace a uMind Studio en la navegación del cliente
La ruta /portal/studio existía pero no había ningún enlace: el cliente
tenía que escribir la URL a mano, o sea que en la práctica no podía
entrar.

El enlace solo aparece si ese cliente tiene al menos un espacio de uMind
asignado — se consulta el mismo endpoint con alcance que usa el SPA, así
ningún cliente sin uMind ve un enlace que lo llevaría a una pantalla
vacía.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:39:24 -05:00
Lizandro GuarnizoandClaude Sonnet 5 6817ad1e6c fix(whisper): acepta la transcripción en texto plano y permite descargarla
El servicio devolvía la transcripción correcta y la reportábamos como
error: "respuesta inesperada del servicio de transcripción: <la
transcripción completa>". La causa es que mandábamos response_format=json,
que es la convención de la API de OpenAI — whisper-asr-webservice usa el
query param `output`, así que ignoró el campo y respondió en su formato
por defecto (txt), y el parser exigía JSON.

- textoDeRespuestaWhisper acepta las dos formas (JSON {"text":...} o texto
  pelado) en vez de adivinar qué variante corre del otro lado. Con test:
  8 casos, incluidos JSON sin campo text y un texto que empieza con "{".
- Whisper y OCR: la caja de resultado ahora muestra el conteo de
  caracteres, scrollea si es largo, y tiene Copiar y Descargar .txt (Blob
  + <a download>, el texto ya está en el navegador).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 20:49:08 -05:00
Lizandro GuarnizoandClaude Sonnet 5 7146539f86 fix(umind): prohíbe inventar URLs y contactos, y hace clickeables los enlaces
El agente estaba dando https://escuelametropolitana.com/login.php, una
ruta que no existe: el prompt decía "no inventes datos" pero no decía
nada de URLs, teléfonos ni correos, que es justo lo que un modelo
confabula con más confianza porque "sabe" que un login suele estar en
/login.php. Un enlace inventado deja a la persona sin poder completar el
trámite, peor que no responder.

- Regla explícita: URLs, teléfonos, WhatsApp y correos solo si aparecen
  LITERALMENTE en los resultados de buscar_conocimiento, copiados carácter
  por carácter, sin completar rutas ni cambiar el dominio.
- Widget: los enlaces se vuelven clickeables. El contenido sale de páginas
  crawleadas, así que NO se inserta como HTML: se parte por URL y cada
  trozo va con createTextNode o un <a> con rel=noopener noreferrer
  nofollow. Verificado que un <img onerror> o un <script> en la respuesta
  quedan escapados como texto inerte.
- WhatsApp: preview_url=true, así el primer enlace se ve como tarjeta
  clickeable en vez de texto pelado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 12:46:46 -05:00
Lizandro GuarnizoandClaude Sonnet 5 4995c5fca5 fix(widget): deja de mostrar los errores del servidor como respuestas del bot
El widget hacía `data.respuesta || data.message` sin mirar el status, así
que un 403/404/500 se pintaba en una burbuja como si lo hubiera dicho el
agente. Imposible distinguir "el bot contestó raro" de "el widget está
siendo rechazado", que es justo el lazo en el que se puede quedar alguien
diagnosticando esto.

Además los rechazos del middleware ocurren ANTES del motor, así que no
dejaban rastro en ningún lado: ni en la auditoría del agente ni en el
navegador. De ahí el síntoma "el chat de prueba anda pero el widget no".

- El widget mira r.ok, manda el motivo real a la consola y al visitante le
  muestra un mensaje neutro.
- AuthUmindWidget distingue las tres causas que el chat de prueba NO tiene
  (agente inactivo, tenant inactivo, dominio no autorizado) y las registra
  en la auditoría del agente con el detalle accionable — incluyendo qué
  dominio llamó y cuáles están permitidos.
- GetUmindAgentePorSiteKey busca sin filtrar por activo, para poder decir
  "está apagado" en vez de "no existe".
- Test del allowlist de dominios: es fail-closed y www.ejemplo.com NO
  matchea ejemplo.com, la causa más probable de este síntoma.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 12:06:42 -05:00
Lizandro GuarnizoandClaude Sonnet 5 d8f9acd1f8 feat(studio): rediseño con design tokens, pantalla de consumo y rename a Studio
El SPA pasa a llamarse uMind Studio y vive en /studio (el nombre
"Orquestador" no le decía nada al cliente final, que ahora es quien lo usa).

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 12:02:54 -05:00
Lizandro GuarnizoandClaude Sonnet 5 023a8af494 feat(cobro): suma el consumo de uMind al ciclo de cobro existente
El cobro recurrente (link de pago, webhook con firma, renovación
automática de vencimiento) ya funcionaba de punta a punta; lo único que
le faltaba para cobrar por uso era monto variable.

- MontoACobrar = PrecioAcordado + consumo pendiente del cliente. Si el
  cliente no usa uMind el consumo es 0 y el monto queda idéntico al de
  antes, así que los contratos existentes no cambian.
- El consumo se agrega por Cliente, no por tenant: un contrato es de un
  cliente y un cliente puede tener varios tenants.
- El desglose (cuántos tokens, imágenes y transcripciones, y a cuánto)
  va como líneas de servicio, así las plantillas de correo existentes lo
  muestran sin tocarlas. Cobrar un monto variable sin decir de dónde sale
  es pedir una disputa.
- Al confirmarse el pago se cierra el consumo (facturado_at). Solo si el
  UPDATE del contrato afectó filas: si el webhook llega dos veces, la
  segunda no vuelve a cerrar nada.
- Test de que el detalle del correo suma exactamente lo que se cobra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:53:40 -05:00
Lizandro GuarnizoandClaude Sonnet 5 5b78f6677c feat(umind): el cliente administra sus agentes desde el portal
Acá es donde uMind deja de ser una herramienta interna: el cliente entra
a /portal/studio con su sesión de portal y gestiona lo suyo.

- UmindScopePortal/UmindScopeStaff es el ÚNICO punto donde se decide el
  alcance. El del cliente sale de GetClienteIDsForPortalUser, el mismo
  que ya autoriza el resto del portal. nil = staff sin restricción,
  slice vacío = no ve nada; una ruta sin scope también cae en "no ve
  nada" para que olvidarse el middleware falle visible y no abra todo.
- Un solo set de handlers montado bajo /app/umind y /portal/umind
  (RegistrarRutasUmind). Duplicarlos sería duplicar las chances de
  olvidar un chequeo.
- Guarda de acceso en TODOS los handlers, incluidos los sub-recursos que
  llegan por :id (documento, tool, canal, conexión): hay que cargarlos
  para saber de quién son, si no un cliente podría borrar el canal de
  otro adivinando el id. Responden 404, no 403: un 403 confirmaría que
  el recurso existe.
- Cierra un bug preexistente: las lecturas GET /app/umind/* no tenían
  SoloAdmin ni pasaban por MenuMiddleware, así que cualquier usuario de
  staff podía leer los tenants de todos los clientes.
- Límite de agentes por plan (409 con mensaje claro). Un tenant sin plan
  no tiene límite: cortarles de golpe sería peor que dejarlos como estaban.
- /umind/ai-configs reemplaza con alcance a /app/api/ai-config/select,
  que devolvía TODAS las configs del sistema.
- El SPA deduce por la URL si es staff o cliente (base del router,
  prefijo de API y URL de login) y oculta lo que es solo de staff.
- Test de aislamiento entre clientes: 7 casos, incluido que un scope
  vacío no se confunda con staff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:51:12 -05:00
Lizandro GuarnizoandClaude Sonnet 5 08265510ea feat(umind): mide el consumo de IA, OCR y transcripción por tenant
Es la base del cobro por uso: hasta ahora no había ninguna medición de
consumo en todo el repo.

- UmindUso registra cada evento facturable con el costo YA calculado al
  precio vigente del plan. Congelarlo evita que subir un precio revalúe
  consumo pasado, que haría indefendible una factura ante un reclamo.
- callAI devuelve los tokens que reportó el proveedor (campo usage, igual
  en todos los OpenAI-compatibles; input+output en Anthropic). Se mide
  cada ronda de tool-calling, no solo la última: todas gastan tokens.
- ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no
  medir, que es lo que pasan los botones "Probar" del panel de staff.
- Aviso al superar el tope del plan, una vez por mes y sin cortar el
  servicio. El flag de "ya avisé" es en memoria a propósito.
- GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle.
- Test del cálculo de costo por tipo, incluida fracción de 1k tokens y
  tenant sin plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:41:23 -05:00
Lizandro GuarnizoandClaude Sonnet 5 96cd24f17d feat(ai-config): cifra la API key en reposo y la acota por tenant
Prepara el terreno para que el cliente cargue su propia cuenta de IA sin
que su clave quede en texto plano ni que el selector le muestre las de
los demás.

- ClaveEnClaro() descifra con fallback a texto plano: las filas viejas se
  leen igual y quedan cifradas al primer guardado, sin script ni downtime.
  utils.Decrypt hace panic (no devuelve error) con entrada que no es un
  ciphertext válido, así que el fallback va sobre recover — eso mismo es
  el mecanismo de detección de "todavía está en claro".
- Migrados TODOS los lectores: uMind (chat y embeddings), bot de Telegram,
  Whisper, Query Runner, streaming de IA y Landing Generator. Un lector
  sin migrar mandaría el ciphertext como API key.
- AiConfig gana TenantID (null = global del staff) y
  GetAiConfigSelectPorTenants para acotar el selector.
- Test de los 4 casos del fallback, incluido hex válido que no descifra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:36:47 -05:00
Lizandro GuarnizoandClaude Sonnet 5 c69b904b03 feat(umind): vincula tenants a clientes y agrega planes con precios
Base para que el cliente administre uMind desde su portal y para el cobro
por consumo. Todavía no cambia nada del comportamiento actual.

- UmindTenant gana ClienteID (punteros, sin not null: los tenants creados
  antes del portal quedan sin asignar y el ALTER TABLE no falla sobre
  datos existentes) y PlanID.
- GetUmindTenantsByClientes es fail-closed: sin clientes, no ve nada.
- UmindPlan define el máximo de agentes y los precios por 1k tokens, por
  imagen OCR, por transcripción y la mensualidad, más un tope de consumo
  que solo avisa. CRUD para staff en /app/umind-planes.
- El modelo va en AMBAS listas de AutoMigrate (main.go y migrations),
  no repetir el error de agregarlo solo en una.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:32:38 -05:00
Lizandro GuarnizoandClaude Sonnet 5 84506a98e2 feat: OCR y Whisper como herramientas opcionales por canal en uMind
Cada canal (Telegram/WhatsApp) de un agente ahora puede activar, de forma
independiente, que los audios entrantes se transcriban con el Whisper ASR
propio y que a las imágenes entrantes se les extraiga texto con el
servicio OCR propio, antes de pasarle el mensaje al agente. Antes esos
mensajes se ignoraban en silencio.

- UmindCanal gana usar_whisper_audio/usar_ocr_imagenes (default off).
- WhatsApp: se descarga el media vía Graph API (resolución de URL + fetch
  con el mismo access_token del canal) y se enruta a Whisper/OCR según type.
- Telegram: se descarga el archivo vía getFile + CDN de archivos del bot,
  mismo enrutamiento para voice/audio/photo.
- Panel: checkboxes en el alta de canal y toggles inline por canal ya
  creado, en la tab Canales del agente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 10:35:48 -05:00
Lizandro GuarnizoandClaude Sonnet 5 d493d6dee6 feat: agrega integraciones OCR y Whisper ASR (servicios propios)
Nuevos submódulos en Integraciones para conectar el servicio propio de
OCR (extracción de texto de imágenes) y el de transcripción de audio
self-hosted (whisper-asr-webservice, Basic Auth), con panel de
configuración y prueba en vivo, siguiendo el patrón ya usado por
WebSMS/Hostinger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 10:09:40 -05:00
Lizandro GuarnizoandClaude Sonnet 5 997e3cc790 feat: log de auditoría técnica por agente
Nueva tabla umind_eventos_log + tab "Auditoría" en el panel: errores y
eventos que hasta ahora solo se podían ver pidiendo los logs del servidor
(fallos al llamar al AI, respuestas sin texto usable, tools/webhooks que
fallan, tokens de correo que no se pueden refrescar, firmas de WhatsApp
inválidas, errores procesando mensajes de Telegram/WhatsApp) quedan
registrados por agente, visibles directo en el panel con el JSON crudo
expandible para diagnosticar sin acceso al servidor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:58:59 -05:00
Lizandro GuarnizoandClaude Sonnet 5 5febbd24d5 fix: no guarda ni reenvía turnos vacíos del asistente en el historial
Una sesión de widget que pegó contra un bug ya arreglado (ej. el
thought_signature de Gemini) podía quedar con un turno "assistant" vacío
guardado en umind_mensajes — como el widget mantiene la misma session_id en
sessionStorage mientras no se cierra la pestaña, cada mensaje nuevo
reenviaba ese historial roto y el modelo volvía a responder vacío en
cascada. El chat de prueba del panel no lo sufría porque arranca session_id
nueva en cada carga de página.

Dos cambios: no se guarda un mensaje "assistant" con contenido vacío de acá
en más, y al armar el prompt se descartan los que ya hayan quedado guardados
así — una sesión vieja rota se autorepara sola en el próximo mensaje, sin
que el visitante tenga que abrir una pestaña nueva.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:52:28 -05:00
Lizandro GuarnizoandClaude Sonnet 5 7c4bb8713b fix: loguea la respuesta cruda del AI cuando no trae texto usable
Cuando el AI respondía sin error pero con un content que no era string
(o vacío), ProcessWidgetMessage caía en silencio al mensaje genérico de
"dame más detalle" sin dejar ningún rastro en el log — no había forma de
saber qué pasó. Ahora loguea el JSON crudo (agentMessage.Raw) en ese caso,
y también cuando se agotan las 3 rondas de tool-calling sin llegar a una
respuesta final.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:43:18 -05:00
Lizandro GuarnizoandClaude Sonnet 5 f3f2f421d6 feat: uMind pasa a multi-agente por tenant
Un tenant (negocio/sitio, dueño de los dominios permitidos) puede tener
varios UmindAgente independientes (ej. "Ventas", "Soporte"), cada uno con
su propia config de IA, tono, base de conocimiento, tools, canales y
conexión de correo. El site_key también pasa a ser por agente, así cada
uno tiene su propio <script> de widget embebible y su propio color.

Backend:
- Nuevo modelo UmindAgente (pkg/models/umind_agente.go), con SiteKey,
  AiConfigID, Tono, MensajeBienvenida y Color — campos que antes vivían en
  UmindTenant y se sacan de ahí (las columnas viejas quedan huérfanas sin
  usar, no se hace DROP COLUMN).
- UmindDocumento, UmindChunk, UmindHerramienta, UmindCanal, UmindConexion y
  UmindMensaje pasan de TenantID a AgenteID. El campo se agrega sin
  "not null" para no romper el ALTER TABLE en Postgres sobre tablas que ya
  tienen filas (ej. emetropolitana).
- migrations.MigrarUmindAgentes(): idempotente, crea un agente "Principal"
  por cada tenant existente heredando lo que ya tenía configurado, y mueve
  sus datos de tenant_id a agente_id. Corre en cada arranque normal, mismo
  criterio que los Seed* — nada se rompe para los tenants ya en producción.
- Motor del agente, widget, canales (Telegram/WhatsApp) y OAuth de correo
  ahora operan sobre UmindAgente; el tenant solo se consulta para el
  chequeo de dominio permitido y el nombre del negocio que ve el visitante.

Frontend: nueva jerarquía de navegación tenant → lista de agentes
(TenantAgentes.vue) → detalle de un agente (AgenteDetail.vue, antes
TenantDetail.vue) con las mismas 6 tabs de siempre, ahora por agente. El
modal de tenant en el sidebar se achica a nombre/dominios/activo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:30:06 -05:00
Lizandro GuarnizoandClaude Sonnet 5 8eba3ab97f fix: preserva campos propietarios del proveedor en el loop de tool-calling
Gemini (modelos "thinking" como 2.5) exige que el tool_call se reenvíe con
su thought_signature intacto en la ronda siguiente, o rechaza con 400
"Function call is missing a thought_signature". Nuestro parseo a
agentMessage/agentToolCall no conocía ese campo y lo descartaba en el
unmarshal, así que cualquier tool call con Gemini fallaba apenas el modelo
pedía usar una herramienta (buscar_conocimiento incluida).

agentMessage ahora guarda los bytes JSON originales cuando se parsea de una
respuesta (UnmarshalJSON) y los reenvía tal cual al volver a serializar
(MarshalJSON) — preserva thought_signature u otro campo propietario que
llegue, sin que el código necesite conocer su nombre/forma exacta. Los
mensajes que armamos nosotros (user/tool/system) no llevan Raw y siguen
serializando normal. Afecta tanto a uMind como al agente de Telegram, que
comparten este mismo motor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:39:09 -05:00
Lizandro GuarnizoandClaude Sonnet 5 e0c2f77f8c perf: achica la ventana de historial de uMind (10 -> 6 mensajes)
Ese historial se reenvía completo en cada turno junto con el system prompt
y las tools — con Gemini como proveedor principal, sin acceso fácil a
caching de contexto (su API de caching no pasa por la capa de
compatibilidad OpenAI que usamos), la forma más directa de bajar el costo
por mensaje en charlas largas es mandar menos historial repetido.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 22:10:40 -05:00
Lizandro GuarnizoandClaude Sonnet 5 9dc44decd7 fix: unifica el mapeo proveedor→URL, el botón "Probar conexión" tenía su propia copia sin Gemini
El fix anterior de Gemini (22efb9c) solo tocó providerDefaultURL, que usan
el chat/embeddings/whisper reales. TestAiConfigHandler (el botón "Probar
conexión" del panel de AI Config) tenía una tercera copia independiente del
mismo mapeo, sin caso para Gemini ni Deepseek — por eso el chat ya
funcionaba con Gemini pero la prueba de conexión seguía fallando.

Se exporta providerDefaultURL a services.ProviderDefaultURL y el controller
la reusa, en vez de mantener una copia más que se desactualiza cada vez que
se agrega un proveedor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 21:38:16 -05:00
Lizandro GuarnizoandClaude Sonnet 5 22efb9ca70 fix: soporte para proveedor Gemini + evita que el agente use Markdown
- providerDefaultURL no tenía caso para "gemini": sin Base URL manual caía
  al default (la URL de OpenAI) y fallaba siempre con la API key de Google.
  Se agrega la capa de compatibilidad OpenAI de Gemini
  (generativelanguage.googleapis.com/v1beta/openai).
- El widget muestra el texto del agente tal cual (textContent, no innerHTML
  — deliberado, evita XSS si algo del contenido crawleado se cuela en una
  respuesta). El modelo respondía con **negrita** y listas en Markdown que
  se veían como asteriscos/guiones sueltos en vez de renderizarse. Se
  instruye al system prompt a no usar Markdown, en vez de sumar un parser
  al widget. Aplica a los tres canales (widget, Telegram, WhatsApp) y al
  chat de prueba del panel, porque todos comparten el mismo prompt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 21:23:30 -05:00
Lizandro GuarnizoandClaude Sonnet 5 de222b8b1d fix: el crawler de uMind acepta texto plano/Markdown, no solo HTML
CrawlearSitio rechazaba cualquier Content-Type que no contuviera
"text/html", así que una URL como base-conocimiento.md (text/plain, pensada
explícitamente para que la consuma una IA) fallaba con "no se pudo extraer
texto de ninguna página" pese a tener contenido perfectamente válido. Ahora
acepta text/plain, text/markdown, o cualquier URL terminada en .md, usando
el body tal cual sin pasarlo por el parser de HTML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 20:51:47 -05:00
Lizandro GuarnizoandClaude Sonnet 5 8c1e754ab3 feat: rediseña el widget embebible (ícono AI, color configurable, animaciones)
El bubble era un emoji 💬 sobre un color fijo hardcodeado en el JS. Ahora:
- Ícono nuevo (spark/sparkle, el motivo visual estándar de "asistente IA"
  hoy) en vez del emoji, con anillo de pulso al cargar para llamar la
  atención sin ser invasivo.
- Color configurable por tenant (UmindTenant.Color, validado como hex en el
  server) — se aplica en tiempo real vía /widget/:site_key/init, así que
  cambiarlo desde el panel no requiere recopiar el <script>.
- Panel con animación de entrada, botón de cerrar explícito, subtítulo
  "Asistente con IA · uMind" y badge de marca — antes solo tenía el nombre
  del tenant y ningún indicio de qué lo potencia.
- El widget ahora se inicializa al cargar el script (no al primer click), así
  el color/nombre reales ya están listos antes de que el visitante abra el chat.

Selector de color agregado al form de tenant en el orquestador (input
type=color + hex).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 20:03:51 -05:00
Lizandro GuarnizoandClaude Sonnet 5 c2e9d2c5a2 feat: código del widget web copiable desde la tab Canales
El widget web no aparecía en ningún lado como "canal" (esa tab solo
mostraba Telegram/WhatsApp), así que no había forma de encontrar el
<script> a pegar en el sitio sin que se lo pidieran a soporte. Se agrega
una card fija "Web (widget)" arriba de la lista de canales, con el snippet
ya armado con el site_key real del tenant y un botón de copiar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 19:42:19 -05:00
Lizandro GuarnizoandClaude Sonnet 5 5ba41786d6 feat: conexiones OAuth (Gmail/Outlook) para el agente + rediseño del orquestador
Backend:
- UmindConexion: cuenta de correo conectada por tenant vía OAuth2
  (golang.org/x/oauth2, promovida de indirecta a directa), tokens cifrados
  en reposo con el mismo AES-GCM+APP_KEY que ya usan tools/canales.
- Flujo completo: /app/umind/conexiones/conectar redirige a Google/Microsoft,
  /callback/:proveedor intercambia el code (state autoverificable por HMAC,
  sin tabla de estados pendientes), refresh on-demand antes de cada uso.
- Dos tools nuevas para el agente (enviar_correo/leer_bandeja) que aparecen
  solo si el tenant tiene una conexión activa, vía Gmail API / Microsoft
  Graph directo (sin el SDK pesado de Google).
- Requiere que el dueño del proyecto cree las apps OAuth en Google Cloud
  Console / Azure y cargue GOOGLE_OAUTH_CLIENT_ID/SECRET y
  MS_OAUTH_CLIENT_ID/SECRET — sin eso los botones de conectar fallan con un
  mensaje claro, no en silencio.

Frontend: rediseño del orquestador — layout de sidebar fijo (reemplaza el
navbar + lista de página completa), modo oscuro vía prefers-color-scheme,
tabs en pill, y la nueva tab "Conexiones".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 10:25:13 -05:00
Lizandro GuarnizoandClaude Sonnet 5 b2f6b518b6 fix: navegar al detalle del tenant tras crearlo, fila clickeable en la lista
El único link a /tenants/:id era el nombre del tenant en texto plano sin
ningún indicio visual de que fuera clickeable — después de crear un tenant
no había forma obvia de llegar a configurarlo (base de conocimiento, tools,
canales). Ahora crear un tenant navega directo a su detalle, y toda la fila
de la lista es clickeable con un "Configurar →" explícito.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 22:28:21 -05:00
Lizandro GuarnizoandClaude Sonnet 5 98c68b0385 fix: agrega UmindHerramienta/UmindCanal al AutoMigrate del arranque normal
Las había agregado solo en migrations/migrate.go (modelosPrincipales, solo
corre con -migrate y ni siquiera levanta el servidor). La lista que
realmente se ejecuta en cada arranque normal (go run . sin flags, que es
como corre en producción) es la de main.go — sin este fix, crear una tool o
un canal fallaba por tabla inexistente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 22:20:16 -05:00
Lizandro GuarnizoandClaude Sonnet 5 da0bffe661 feat: orquestador uMind (SPA Vue) + tools custom + canales Telegram/WhatsApp
SPA nueva en /orchestrator (Vue 3 + Vite, servida por el mismo binario Go
bajo /orchestrator para que la cookie de sesión funcione sin tocar CORS),
reemplaza al panel Alpine.js como punto de entrada del menú.

Backend, todo aditivo sobre el motor de uMind ya existente:
- UmindHerramienta: tools custom por tenant que llaman un webhook HTTP,
  integradas al loop de function-calling existente. Cliente HTTP con
  guardas SSRF (bloqueo de IPs privadas/loopback/link-local resuelto en el
  momento de conectar, no antes, para cerrar la ventana de DNS rebinding)
  que no existían en el proyecto.
- UmindCanal: Telegram y WhatsApp Business Cloud API como canales
  adicionales del mismo agente que ya atiende el widget web, ambos
  reusando ProcessWidgetMessage. WhatsApp valida X-Hub-Signature-256.
  Credenciales cifradas en reposo con el mismo AES-GCM+APP_KEY que ya usa
  el proyecto para la contraseña SMTP (primer uso para secretos de uMind).
- Se conecta middlewares.Limit() (rate limiter que existía pero no se
  usaba en ningún lado) al widget público y a los webhooks nuevos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 22:16:20 -05:00