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>
This commit is contained in:
Lizandro Guarnizo
2026-08-19 18:21:46 -05:00
co-authored by Claude Opus 5
parent 3ea17b0980
commit af71b66bb8
17 changed files with 1797 additions and 38 deletions
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
<title>uMind Studio</title>
</head>
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
+9 -3
View File
@@ -140,9 +140,15 @@ onMounted(() => {
md:static md:h-screen md:sticky md:top-0 md:translate-x-0"
:class="menuAbierto ? 'translate-x-0' : '-translate-x-full'"
>
<div class="h-14 px-4 flex items-center border-b border-borde">
<router-link to="/" class="text-base font-semibold text-texto">
uMind <span class="text-brand">Studio</span>
<div class="h-14 px-4 flex items-center gap-2 border-b border-borde">
<router-link to="/" class="flex items-center gap-2 text-base font-semibold text-texto">
<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>
</div>
+41 -3
View File
@@ -62,23 +62,30 @@ const cupo = computed(() => {
})
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() {
error.value = ''
cargando.value = true
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/agentes?tenant_id=${props.id}`)),
api.get(apiUmind('/umind/ai-configs')),
api.get(apiUmind('/umind/plantillas-rubro')),
])
tenant.value = (t.items || []).find((x) => String(x.ID) === props.id) || null
agentes.value = a.items || []
plan.value = a.plan || null
resumen.value = a.resumen || {}
aiConfigs.value = ai.items || []
plantillas.value = pl.items || []
} catch (e) {
error.value = e.message
} finally {
@@ -231,13 +238,44 @@ watch(() => props.id, cargar, { immediate: true })
</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 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>
<form class="space-y-3" @submit.prevent="guardar">
<div>
<label class="label">Nombre</label>
<input v-model="form.nombre" required placeholder="ej: Ventas, Soporte" class="input" />
</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>
<label class="label">Config de IA</label>
<select v-model="form.ai_config_id" class="input">
+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)
}
}
}
+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>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' rx='22' fill='%238eb02f'/%3E%3Cpath d='M32,42 V58 A14,14 0 0 0 60,58 V42' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M60,58 V64' fill='none' stroke='%23fff' stroke-width='10' stroke-linecap='round'/%3E%3Ccircle cx='60' cy='28' r='7' fill='%23fff'/%3E%3C/svg%3E" />
<title>uMind Studio</title>
<script type="module" crossorigin src="/orchestrator/assets/index-D4CbBy2Y.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-C_8yw0Pk.css">
<script type="module" crossorigin src="/orchestrator/assets/index-CbSZEemk.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-Culm0hM7.css">
</head>
<!-- Sin clase de fondo: el color lo pone body en style.css desde los tokens,
que son los que cambian con el tema. Una utilidad acá le ganaba a la
+1291
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -74,7 +74,7 @@
class="ml-auto mr-3">
<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">
<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
campana y el nombre de usuario, pero el acceso no puede faltar. -->
<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. -->
{{ 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">
<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">
<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>
@@ -37,7 +37,7 @@
{{ else }}
<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">
<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">
<p class="font-semibold text-slate-800 text-sm">Tu WhatsApp puede responder solo</p>
<p class="text-slate-600 text-sm mt-1">
@@ -198,6 +198,9 @@ type umindAgenteReq struct {
MensajeBienvenida string `json:"mensaje_bienvenida"`
Color string `json:"color"`
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 {
@@ -233,6 +236,14 @@ func CreateUmindAgenteHandler(c *fiber.Ctx) error {
if err := models.CreateUmindAgente(agente); err != nil {
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})
}
@@ -1022,3 +1033,10 @@ func ActualizarUmindDocumentoHandler(c *fiber.Ctx) error {
}
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()})
}
+1
View File
@@ -37,6 +37,7 @@ func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Ha
g.Put("/umind/agentes/:id", w(controllers.UpdateUmindAgenteHandler)...)
g.Delete("/umind/agentes/:id", w(controllers.DeleteUmindAgenteHandler)...)
g.Get("/umind/plantillas-rubro", r(controllers.GetPlantillasRubroHandler)...)
g.Get("/umind/documentos", r(controllers.GetUmindDocumentosHandler)...)
g.Post("/umind/documentos", w(controllers.CreateUmindDocumentoHandler)...)
g.Post("/umind/documentos/texto", w(controllers.CreateUmindTextoHandler)...)