Compare commits

..

2 Commits

Author SHA1 Message Date
Jordan Diaz
237dc00379 nah 2026-04-09 20:46:03 +00:00
Jordan Diaz
4c73d848bb Primera fase context 2026-04-09 18:27:36 +00:00
13 changed files with 1462 additions and 1245 deletions

View File

@@ -1,695 +0,0 @@
# Ejemplos de Builder Vue - Producción
Colección de ejemplos reales de archivos `builder.vue` implementados en producción. Cada ejemplo incluye el código completo y notas sobre decisiones de diseño importantes.
---
## Ejemplo 1: Banner Slideshow
### Descripción
Banner hero con slideshow de imágenes o video de fondo, overlay configurable, textos principales (pretítulo, título, subtítulo) y botón de llamada a la acción.
### Características principales
- **5 tabs organizados**: Configuración, Imágenes, Textos, Enlaces, Colores
- **Selector imagen/video**: Toggle con iconos que alterna entre imagen y video con `v-show`
- **Overlay completo**: Tipo (sin degradado/con degradado), color y opacidad agrupados en tab Imágenes
- **Colorpickers**: Para overlay y color de texto general con textfield oculto
- **Toggles con iconos**: Sombra (X/check), tipo imagen (foto/video), tipo overlay (cuadrado/degradado)
- **Logo adicional**: Upload de logo que se superpone al banner
- **Configuraciones globales**: Posición texto, sombra, container, altura banner
### Decisiones de diseño clave
1. **Selector imagen/video como primer campo del tab Imágenes**: El toggle de tipo de fondo está al inicio del tab Imágenes, antes de los uploads, según la regla 10.1
2. **v-show en uploads**:
- Upload de imágenes: `v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == ''"`
- Upload de video: `v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == '1'"`
- NUNCA quitar estos `v-show`, son esenciales
3. **Grupo overlay en tab Imágenes**: El grupo completo (tipo + color + opacidad) está en Imágenes, NO en Colores, porque afecta directamente al fondo visual (regla 10.2)
4. **Radio borde en tab Enlaces**: Campo que afecta al botón va en el tab del enlace, no en Configuración (regla 10.3)
5. **Recuerda con HTML escapado**: El campo título incluye un "Recuerda" con etiquetas HTML escapadas (`<span>`) para guiar al usuario
6. **Color del texto en tab Colores**: El color general del texto va en su propio tab, no mezclado con el overlay
### Tabs configurados
```javascript
tabsConfig: [
{ id: "configuracion", label: "Configuración", color: "#f59e0b", icon: '<svg>...</svg>' },
{ id: "imagenes", label: "Imágenes", color: "#10b981", icon: '<svg>...</svg>' },
{ id: "textos", label: "Textos", color: "#3b82f6", icon: '<svg>...</svg>' },
{ id: "enlaces", label: "Enlaces", color: "#ef4444", icon: '<svg>...</svg>' },
{ id: "colores", label: "Colores", color: "#8b5cf6", icon: '<svg>...</svg>' }
]
```
### Componentes utilizados
- `acai-vue-tabs` - Sistema de tabs con storage-key y apply-theme-styles
- `acai-vue-selectv2` - Selectores (algunos con `:toggle-icons`)
- `acai-vue-textfield` - Campos de texto simple (pretítulo, subtítulo)
- `acai-vue-title` - Encabezado principal con placeholder
- `acai-vue-linkv2` - Enlaces con `:show_text="true"`
- `acai-vue-upload` - Uploads de imagen/video/logo con todas las props necesarias
- `acai-vue-colorpicker` - Pickers de color con textfield oculto asociado
### Iconos con toggle
```javascript
iconosSombra: {
'': '<svg>...(icon-tabler-x)</svg>',
'1': '<svg>...(icon-tabler-check)</svg>'
},
iconosTipoImagen: {
'': '<svg>...(icon-tabler-photo)</svg>',
'1': '<svg>...(icon-tabler-video)</svg>'
},
iconosOverlay: {
'': '<svg>...(icon-tabler-square)</svg>',
'1': '<svg>...(icon-tabler-gradient)</svg>'
}
```
### Código completo
```vue
<template>
<div v-if="data">
<acai-vue-tabs v-if="data" :tabs="tabsConfig" :storage-key="'banner-slideshow-tabs-' + (section_id || 'default')" :apply-theme-styles="true">
<!-- TAB: CONFIGURACIÓN -->
<template #configuracion="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Ajustes generales del banner</p>
</div>
<!-- Lado texto -->
<div class="flex w-full items-center">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M9 15h-2" /><path d="M13 12h-6" /><path d="M11 9h-4" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Posición del texto :</b> Define la alineación del contenido dentro del banner.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'ladotexto'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
<!-- Ver sombra -->
<div class="w-full items-center mt-6">
<div class="w-full flex items-center">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-shadow"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M13 12h5" /><path d="M13 15h4" /><path d="M13 18h1" /><path d="M13 9h4" /><path d="M13 6h1" /></svg>
</div>
<div class="relative">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'versombra'" :toggle-icons="iconosSombra" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Sombra en textos :</b> Aplica un efecto de sombra a los textos del banner.</p>
</div>
<!-- Container -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-arrow-autofit-width"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6" /><path d="M10 18h-7" /><path d="M21 18h-7" /><path d="M6 15l-3 3l3 3" /><path d="M18 15l3 3l-3 3" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Ancho del contenedor :</b> Limita el ancho máximo del contenido textual.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto ocupa todo el ancho disponible (Full container).</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'container'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
<!-- Altura banner -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-arrow-autofit-height"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 20h-6a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h6" /><path d="M18 14v7" /><path d="M18 3v7" /><path d="M15 18l3 3l3 -3" /><path d="M15 6l3 -3l3 3" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Altura del banner :</b> Altura visible de la sección del banner.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto es pantalla completa (100vh).</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'alturadelbanner'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
</template>
<!-- TAB: IMÁGENES -->
<template #imagenes="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Fondo y elementos visuales del banner</p>
</div>
<!-- Tipo de imagen (selector imagen/video) -->
<div class="w-full items-center mt-6">
<div class="w-full flex items-center">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-photo-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15h-3a3 3 0 0 1 -3 -3v-6a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v3" /><path d="M9 12a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3l0 -6" /><path d="M3 12l2.296 -2.296a2.41 2.41 0 0 1 3.408 0l.296 .296" /><path d="M14 13.5v3l2.5 -1.5l-2.5 -1.5" /><path d="M7 6v.01" /></svg>
</div>
<div class="relative">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'tipodeimagen'" :toggle-icons="iconosTipoImagen" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Tipo de fondo :</b> Selecciona si el fondo del banner será una imagen o un vídeo.</p>
</div>
<!-- Imágenes (visible cuando es imagen o vacío) -->
<div class="flex w-full items-center mt-6" v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == ''">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" :style="{ color: color }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M15 6l.01 0" /><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -8" /><path d="M3 13l4 -4a3 5 0 0 1 3 0l4 4" /><path d="M13 12l2 -2a3 5 0 0 1 3 0l3 3" /><path d="M8 21l.01 0" /><path d="M12 21l.01 0" /><path d="M16 21l.01 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Imágenes :</b> Añade las imágenes que rotarán en el slideshow del banner.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-upload ref="upload_imagenes" :reference="'upload_imagenes'" :tablename="'builder_custom'" :fieldname="builder.vars.imagenes.relations.builder_custom" :recordnum="data.imagenes.recordNum" :field="data.imagenes" :builder_field="builder.vars.imagenes" :presavetempid="data.imagenes.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('imagenes',data,false,'upload_imagenes')" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
</div>
</div>
</div>
<!-- Video (visible cuando es video) -->
<div class="flex w-full items-center mt-6" v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == '1'">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4z" /><path d="M3 6m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Vídeo :</b> Sube el vídeo de fondo del banner.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-upload ref="upload_video" :reference="'upload_video'" :tablename="'builder_custom'" :fieldname="builder.vars.video.relations.builder_custom" :recordnum="data.video.recordNum" :field="data.video" :builder_field="builder.vars.video" :presavetempid="data.video.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('video',data,false,'upload_video')" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
</div>
</div>
</div>
<!-- Logo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-icons"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6.5 6.5m-3.5 0a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0" /><path d="M2.5 21h8l-4 -7z" /><path d="M14 3l7 7" /><path d="M14 10l7 -7" /><path d="M14 14h7v7h-7z" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Logo :</b> Imagen del logotipo que aparecerá sobre el banner.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-upload ref="upload_logo" :reference="'upload_logo'" :tablename="'builder_custom'" :fieldname="builder.vars.logo.relations.builder_custom" :recordnum="data.logo.recordNum" :field="data.logo" :builder_field="builder.vars.logo" :presavetempid="data.logo.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('logo',data,false,'upload_logo')" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
</div>
</div>
</div>
<!-- Tipo de overlay -->
<div class="w-full items-center mt-6">
<div class="w-full flex items-center">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-background"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 8l4 -4" /><path d="M14 4l-10 10" /><path d="M4 20l16 -16" /><path d="M20 10l-10 10" /><path d="M20 16l-4 4" /></svg>
</div>
<div class="relative">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'tipodeoverlay'" :toggle-icons="iconosOverlay" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Tipo de overlay :</b> Elige si la capa de color se aplica de forma uniforme o con degradado.</p>
</div>
<!-- Color del overlay -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-test-pipe"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M20 8.04l-12.122 12.124a2.857 2.857 0 1 1 -4.041 -4.04l12.122 -12.124" /><path d="M7 13h8" /><path d="M19 15l1.5 1.6a2 2 0 1 1 -3 0l1.5 -1.6" /><path d="M15 3l6 6" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color del overlay :</b> Color y opacidad de la capa que se superpone sobre la imagen o vídeo.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto el overlay es transparente.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordeloverlay'" :label="'Color overlay'" :color="'transparent'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colordeloverlay'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
</template>
<!-- TAB: TEXTOS -->
<template #textos="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Contenido textual del banner</p>
</div>
<!-- Pretítulo -->
<div class="flex w-full items-center">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-text-size"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7v-2h13v2" /><path d="M10 5v14" /><path d="M12 19h-4" /><path d="M15 13v-1h6v1" /><path d="M18 12v7" /><path d="M17 19h2" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Pretítulo :</b> Texto que aparece encima del título principal.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-textfield :builder="builder" :data="data" :field="'pretitulo'" :placeholder="'Ej: Bienvenidos'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Título -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" viewBox="0 0 24 24" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M7 12h10" /><path d="M7 4v16" /><path d="M17 4v16" /><path d="M15 20h4" /><path d="M15 4h4" /><path d="M5 20h4" /><path d="M5 4h4" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Título :</b> Encabezado principal del banner.</p>
</div>
<p class="text-xs leading-snug text-gray-600 font-light mt-2"><b class="text-black">Recuerda :</b> utiliza las etiquetas <span class="text-black font-semibold">&lt;span&gt; &lt;/span&gt;</span> para resaltar las palabras clave.</p>
<div class="relative mt-2 ml-14">
<acai-vue-title :builder="builder" :data="data" :field="'titulo'" placeholder="Título del banner" @save-data="saveData"></acai-vue-title>
</div>
</div>
</div>
<!-- Subtítulo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-text-size"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7v-2h13v2" /><path d="M10 5v14" /><path d="M12 19h-4" /><path d="M15 13v-1h6v1" /><path d="M18 12v7" /><path d="M17 19h2" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Subtítulo :</b> Texto que aparece debajo del título principal.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-textfield :builder="builder" :data="data" :field="'subtitulo'" :placeholder="'Ej: Tu solución ideal'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
</template>
<!-- TAB: ENLACES -->
<template #enlaces="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Botón de llamada a la acción</p>
</div>
<!-- Enlace -->
<div class="flex w-full items-center">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentcolor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M9 15l6 -6" /><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" /><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Enlace :</b> Configura el botón con su texto y destino.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-linkv2 :builder="builder" :data="data" :field="'enlace'" @save-data="saveData" :show_text="true"></acai-vue-linkv2>
</div>
</div>
</div>
<!-- Radio borde enlace -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-border-radius"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-4a4 4 0 0 1 4 -4h4" /><path d="M16 4l0 .01" /><path d="M20 4l0 .01" /><path d="M20 8l0 .01" /><path d="M20 12l0 .01" /><path d="M4 16l0 .01" /><path d="M20 16l0 .01" /><path d="M4 20l0 .01" /><path d="M8 20l0 .01" /><path d="M12 20l0 .01" /><path d="M16 20l0 .01" /><path d="M20 20l0 .01" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Radio del borde :</b> Redondeo de las esquinas del botón de enlace.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto es 'sm' (ligeramente redondeado).</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'radiobordeenlace'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
</template>
<!-- TAB: COLORES -->
<template #colores="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Personalización de colores</p>
</div>
<!-- Color del texto -->
<div class="flex w-full items-center">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M8.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M12.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M16.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color del texto :</b> Color general de todos los textos del banner.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto es blanco (#ffffff).</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordeltexto'" :label="'Color del texto'" :color="'#ffffff'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colordeltexto'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
</template>
</acai-vue-tabs>
</div>
</template>
<style scoped></style>
<script>
module.exports = {
props: ["active", "section_id"],
data() {
return {
data: null,
builder: null,
idiomas: IDIOMAS,
tabsConfig: [
{ id: "configuracion", label: "Configuración", color: "#f59e0b", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>' },
{ id: "imagenes", label: "Imágenes", color: "#10b981", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M11 20h-4a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v4" /><path d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l3 3" /><path d="M14 14l1 -1c.31 -.298 .644 -.497 .987 -.596" /><path d="M18.42 15.61a2.1 2.1 0 0 1 2.97 2.97l-3.39 3.42h-3v-3l3.42 -3.39" /></svg>' },
{ id: "textos", label: "Textos", color: "#3b82f6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 10h-14" /><path d="M5 6h14" /><path d="M14 14h-9" /><path d="M5 18h6" /><path d="M18 15v6" /><path d="M15 18h6" /></svg>' },
{ id: "enlaces", label: "Enlaces", color: "#ef4444", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15l6 -6"/><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"/><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"/></svg>' },
{ id: "colores", label: "Colores", color: "#8b5cf6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" /><path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" /><path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" /><path d="M17 17l0 .01" /></svg>' },
],
iconosSombra: {
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-shadow-off"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5.634 5.638a9 9 0 0 0 12.728 12.727m1.68 -2.32a9 9 0 0 0 -12.086 -12.088" /><path d="M16 12h2" /><path d="M13 15h2" /><path d="M13 18h1" /><path d="M13 9h4" /><path d="M13 6h1" /><path d="M3 3l18 18" /></svg>',
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-shadow"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M13 12h5" /><path d="M13 15h4" /><path d="M13 18h1" /><path d="M13 9h4" /><path d="M13 6h1" /></svg>'
},
iconosTipoImagen: {
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-photo"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" /><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3" /></svg>',
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4z" /><path d="M3 6m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" /></svg>'
},
iconosOverlay: {
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-square"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /></svg>',
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-gradient"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M7 3v18" /><path d="M3 14h4" /><path d="M3 10h4" /><path d="M3 6h4" /><path d="M3 18h4" /></svg>'
}
};
},
components: {
'acai-vue-tabs': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetabs.vue?timestamp=' + new Date().getTime()),
"acai-vue-selectv2": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivueselect.vue?timestamp=" + new Date().getTime()),
"acai-vue-colorpicker": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuecolorpicker.vue?timestamp=" + new Date().getTime()),
"acai-vue-upload": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivueupload.vue?timestamp=" + new Date().getTime()),
"acai-vue-title": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetitle.vue?timestamp=" + new Date().getTime()),
"acai-vue-linkv2": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuelinkv2.vue?timestamp=" + new Date().getTime()),
"acai-vue-textfield": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetextfield.vue?timestamp=" + new Date().getTime()),
},
mounted() { this.$emit("child-mounted"); },
methods: { saveData() { this.$emit("save-data"); } },
};
</script>
```
---
## Ejemplo 2: [Módulo de texto genérico]
```vue
<template>
<acai-vue-tabs v-if="data" :tabs="tabsConfig" :storage-key="'texto-cabecera-tabs-' + (section_id || 'default')" :apply-theme-styles="true">
<!-- Tab Configuración -->
<template #config="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Configuración general del módulo</p>
</div>
<!-- Formato (2 opciones = toggle) -->
<div class="w-full items-center mt-6">
<div class="w-full flex items-center">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-layout"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z" /><path d="M4 13m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z" /><path d="M14 4m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z" /></svg>
</div>
<div class="relative">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'formato'" :toggle-icons="iconosFormato" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Formato :</b> Vertical (todo en una columna) u Horizontal (cabecera y texto en 2 columnas).</p>
</div>
<!-- Alineación texto -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-align-justified"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 6l16 0" /><path d="M4 12l16 0" /><path d="M4 18l12 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Alineación texto :</b> Alineación del contenido.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es izquierda.</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'alineaciontexto'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
<!-- Container texto -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-arrow-autofit-width"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6" /><path d="M10 18h-7" /><path d="M21 18h-7" /><path d="M6 15l-3 3l3 3" /><path d="M18 15l3 3l-3 3" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Container texto :</b> Ancho máximo del contenido.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto ocupa el ancho completo.</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'containertexto'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
<!-- Estilo enlace -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-click"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12l3 0" /><path d="M12 3l0 3" /><path d="M7.8 7.8l-2.2 -2.2" /><path d="M16.2 7.8l2.2 -2.2" /><path d="M7.8 16.2l-2.2 2.2" /><path d="M12 12l9 3l-4 2l-2 4l-3 -9" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Estilo enlace :</b> Estilo visual del botón.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto usa el color principal (Main color).</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'estiloenlace'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
<!-- Radio borde enlace -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-border-radius"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-4a4 4 0 0 1 4 -4h4" /><path d="M16 4l0 .01" /><path d="M20 4l0 .01" /><path d="M20 8l0 .01" /><path d="M20 12l0 .01" /><path d="M4 16l0 .01" /><path d="M20 16l0 .01" /><path d="M4 20l0 .01" /><path d="M8 20l0 .01" /><path d="M12 20l0 .01" /><path d="M16 20l0 .01" /><path d="M20 20l0 .01" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Radio borde enlace :</b> Redondeo del botón.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es "sm".</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'radiobordeenlace'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
</template>
<!-- Tab Textos -->
<template #textos="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Contenido textual del módulo</p>
</div>
<!-- Pretítulo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-text-size"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7v-2h13v2" /><path d="M10 5v14" /><path d="M12 19h-4" /><path d="M15 13v-1h6v1" /><path d="M18 12v7" /><path d="M17 19h2" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Pretítulo :</b> Texto que aparece encima del título principal.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-textfield :builder="builder" :data="data" :field="'pretitulo'" :placeholder="'Ej: Descubre más'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Título -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-heading"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 12h10" /><path d="M7 5v14" /><path d="M17 5v14" /><path d="M15 19h4" /><path d="M15 5h4" /><path d="M5 19h4" /><path d="M5 5h4" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Título :</b> Encabezado principal del módulo.</p>
</div>
<p class="text-xs leading-snug text-gray-600 font-light mt-2"><b class="text-black">Recuerda :</b> utiliza las etiquetas <span class="text-black font-semibold">&lt;span&gt; &lt;/span&gt;</span> para resaltar las palabras clave.</p>
<div class="relative mt-2 ml-14">
<acai-vue-title :builder="builder" :data="data" :field="'titulo'" placeholder="Título del módulo" @save-data="saveData"></acai-vue-title>
</div>
</div>
</div>
<!-- Subtítulo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-text-size"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7v-2h13v2" /><path d="M10 5v14" /><path d="M12 19h-4" /><path d="M15 13v-1h6v1" /><path d="M18 12v7" /><path d="M17 19h2" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Subtítulo :</b> Texto que aparece debajo del título principal.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-textfield :builder="builder" :data="data" :field="'subtitulo'" :placeholder="'Ej: Conoce nuestros servicios'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Texto -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-file-text"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" /><path d="M9 9l1 0" /><path d="M9 13l6 0" /><path d="M9 17l6 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Texto :</b> Contenido descriptivo principal.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-wysiwyg :builder="builder" :data="data" :field="'texto'" @save-data="saveData"></acai-vue-wysiwyg>
</div>
</div>
</div>
</template>
<!-- Tab Enlaces -->
<template #enlaces="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Enlaces del módulo</p>
</div>
<!-- Enlace -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-link"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15l6 -6" /><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" /><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Enlace :</b> Botón de acción principal.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Si no se configura, el botón no se mostrará.</p>
<div class="relative mt-2 ml-14">
<acai-vue-linkv2 :builder="builder" :data="data" :field="'enlace'" :show_text="true" @save-data="saveData"></acai-vue-linkv2>
</div>
</div>
</div>
</template>
<!-- Tab Colores -->
<template #colores="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Personalización de colores</p>
</div>
<!-- Color de fondo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-paint"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z" /><path d="M19 6h1a2 2 0 0 1 2 2a5 5 0 0 1 -5 5l-5 0v2" /><path d="M10 15m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color de fondo :</b> Color de fondo de toda la sección.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es transparente.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordefondo'" :label="'Color de fondo'" :color="'transparent'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colordefondo'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Color del pretítulo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-palette"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M7.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M15.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color del pretítulo :</b> Color del texto del pretítulo.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es #111827.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordelpretitulo'" :label="'Color pretítulo'" :color="'#111827'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colordelpretitulo'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Color del título -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-palette"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M7.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M15.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color del título :</b> Color del texto del título principal.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es #111827.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordeltitulo'" :label="'Color título'" :color="'#111827'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colordeltitulo'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Color título resaltado -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-color-swatch"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" /><path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" /><path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" /><path d="M17 17l0 .01" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color título resaltado :</b> Color de las palabras resaltadas con &lt;span&gt;.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto usa el color principal (Main color).</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'colortituloresaltado'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
<!-- Color del subtítulo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-palette"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M7.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M15.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color del subtítulo :</b> Color del texto del subtítulo.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es #111827.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordelsubtitulo'" :label="'Color subtítulo'" :color="'#111827'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colordelsubtitulo'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Color texto -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-typography"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 20l3 0" /><path d="M14 20l7 0" /><path d="M6.9 15l6.9 0" /><path d="M10.2 6.3l5.8 13.7" /><path d="M5 20l6 -16l2 0l7 16" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color texto :</b> Color del contenido descriptivo.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es #374151.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colortexto'" :label="'Color texto'" :color="'#374151'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colortexto'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
</template>
</acai-vue-tabs>
</template>
<script>
module.exports = {
props: ["active", "section_id"],
data() {
return {
data: null,
builder: null,
idiomas: IDIOMAS,
tabsConfig: [
{ id: "config", label: "Configuración", color: "#f59e0b", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>' },
{ id: "textos", label: "Textos", color: "#3b82f6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 10h-14" /><path d="M5 6h14" /><path d="M14 14h-9" /><path d="M5 18h6" /><path d="M18 15v6" /><path d="M15 18h6" /></svg>' },
{ id: "enlaces", label: "Enlaces", color: "#ef4444", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15l6 -6" /><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" /><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" /></svg>' },
{ id: "colores", label: "Colores", color: "#8b5cf6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" /><path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" /><path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" /><path d="M17 17l0 .01" /></svg>' }
],
iconosFormato: {
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-layout-rows"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" /><path d="M4 12l16 0" /></svg>',
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-layout-columns"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" /><path d="M12 4l0 16" /></svg>'
}
};
},
components: {
'acai-vue-tabs': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetabs.vue?timestamp=' + new Date().getTime()),
'acai-vue-selectv2': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivueselect.vue?timestamp=' + new Date().getTime()),
'acai-vue-colorpicker': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuecolorpicker.vue?timestamp=' + new Date().getTime()),
'acai-vue-linkv2': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuelinkv2.vue?timestamp=' + new Date().getTime()),
'acai-vue-title': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetitle.vue?timestamp=' + new Date().getTime()),
'acai-vue-wysiwyg': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuewysiwyg.vue?timestamp=' + new Date().getTime()),
'acai-vue-textfield': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetextfield.vue?timestamp=' + new Date().getTime()),
},
mounted() { this.$emit("child-mounted"); },
methods: { saveData() { this.$emit("save-data"); } },
};
</script>
```

View File

@@ -1,484 +0,0 @@
# Reglas CMS-VUE
Aplica estas reglas ÚNICAMENTE cuando el usuario incluya "cms-vue" o "CMS-VUE" (en cualquier combinación de mayúsculas/minúsculas) en su mensaje. Ejemplos válidos: "dame el cms-vue", "cms-vue personalizado", "crea el CMS-VUE", "necesito el cms-vue de este módulo". Si el mensaje NO contiene "cms-vue", ignora completamente estas instrucciones.
---
## 1. Estructura general: Tabs (`acai-vue-tabs`)
- Analiza el HTML proporcionado para determinar cuántos tabs son necesarios y cómo nombrarlos.
- Tabs base comunes: **Configuración**, **Imágenes**, **Textos**, **Bloques** (records), **Enlaces**, **Colores**.
- Añade tabs adicionales si el módulo lo requiere (ej: "Formulario", "Video", "Overlay", "Slider", etc.).
- Si un tab solo tendría 1 campo, evalúa fusionarlo con otro tab relacionado.
- Cada tab tiene su propio `id`, `label`, `color` e `icon` (SVG inline).
- SVG dentro del template usan `:style="{ color: color }"` para heredar el color del tab.
- Textos descriptivos claros y orientados al usuario final del CMS.
- Usa `storage-key` único: `'nombre-modulo-tabs-' + (section_id || 'default')`.
- Siempre añade `:apply-theme-styles="true"`.
- **IMPORTANTE:** La prop para pasar los tabs es `:tabs` (NO `:tabs-config`).
- **IMPORTANTE:** Siempre añadir `v-if="data"` en el `<acai-vue-tabs>` para evitar renderizar antes de que los datos estén listos.
### Template de cada tab:
```html
<template #idtab="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Título descriptivo del tab</p>
</div>
<!-- campos -->
</template>
```
---
## 2. Colorpicker según contexto
### 2.1 En tab "Colores" (campos generales de color de texto/fondo)
Siempre con SVG + título + descripción + colorpicker + textfield oculto:
```html
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" ...>...</svg>
<p class="leading-snug text-gray-600"><b class="text-black">Nombre :</b> Descripción.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> valor por defecto.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'campo'" :label="'Etiqueta'" :color="'#hex'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'campo'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
```
### 2.2 Colorpicker en otros tabs (ej: color del overlay en tab Imágenes)
Misma estructura con SVG + título + descripción + colorpicker + textfield oculto, pero usando el `color` del tab donde se encuentre. Se coloca junto a los campos relacionados (ver regla 10.2).
### 2.3 Dentro de `<acai-vue-records>` (sin icono ni descripción)
Se coloca debajo del campo al que corresponde:
- Nota con `mt-4` si campo anterior es `textfield` o `title`.
- Nota con `mt-3` si campo anterior es `textbox` o `wysiwyg`.
- Colorpicker siempre con `mt-1`.
```html
<p class="text-xs leading-snug text-gray-500 mt-4 ml-14"><b class="text-gray-700">Nota :</b> color por defecto (#hex).</p>
<div class="relative mt-1 ml-14">
<acai-vue-colorpicker :builder="builder.vars.records" :data="record" :field="'campo'" :label="'Etiqueta'" :color="'#hex'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder.vars.records" :data="record" :field="'campo'" @save-data="saveData"></acai-vue-textfield>
</div>
```
### 2.4 Campos tipo `list` para colores
Se usan como `<acai-vue-selectv2>` con icono y nota. El componente detecta automáticamente si las opciones son colores y muestra el modo color selector con swatches. No llevan colorpicker ni textfield oculto.
### 2.5 Extraer color por defecto
Del HTML: `style="color: {{ campo ? campo : '#HEX' }}"` → usar `#HEX`. Si no hay color, usar `#111827` (textos) o `transparent` (fondos).
---
## 3. Campos: estructura en tabs
### Fuera de records:
```html
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" ...>...</svg>
<p class="leading-snug text-gray-600"><b class="text-black">Nombre :</b> Descripción.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> info adicional.</p>
<div class="relative mt-2 ml-14">
<!-- componente -->
</div>
</div>
</div>
```
### Dentro de records:
```html
<div class="w-full mt-6">
<div class="flex items-center">
<svg class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" ...>...</svg>
<p class="leading-snug text-gray-600"><b class="text-black">Nombre :</b> Descripción.</p>
</div>
<div class="relative mt-2 ml-14">
<!-- componente con builder.vars.records y record -->
</div>
</div>
```
---
## 4. Nombres de campos (`:field`)
- Construir uniendo palabras del `data-field-label` en minúsculas sin espacios.
- Eliminar acentos: á→a, é→e, í→i, ó→o, ú→u, ñ→(eliminar).
- Ejemplos: `Color del título``colordeltitulo`, `Valoración``valoracion`, `Tamaño``tamao`.
---
## 5. Upload de imágenes
### General:
```html
<acai-vue-upload ref="upload_campo" :reference="'upload_campo'" :tablename="'builder_custom'" :fieldname="builder.vars.campo.relations.builder_custom" :recordnum="data.campo.recordNum" :field="data.campo" :builder_field="builder.vars.campo" :presavetempid="data.campo.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('campo',data,false,'upload_campo')" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
```
### En records:
```html
<acai-vue-upload :ref="'upload_campo_' + builder.vars.records.vars.campo.relations.builder_custom + '_' + record.campo.recordNum" :reference="'upload_campo_' + builder.vars.records.vars.campo.relations.builder_custom + '_' + record.campo.recordNum" :tablename="'builder_custom'" :fieldname="builder.vars.records.vars.campo.relations.builder_custom" :recordnum="record.campo.recordNum" :field="record.campo" :builder_field="builder.vars.records.vars.campo" :presavetempid="record.campo.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('campo',record,true,'upload_campo_' + builder.vars.records.vars.campo.relations.builder_custom + '_' + record.campo.recordNum)" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
```
---
## 6. Componentes y URLs
Solo incluir los que se usen. Los componentes personalizados (tabs, selectv2) se cargan desde impulse; los estándar desde cocosolution:
```javascript
// ── Componentes personalizados (impulse) ──
'acai-vue-tabs': httpVueLoader('https://impulse.webserver2.plandeweb.com/template/estandar/css/builder-acaivuetabsv2.vue?timestamp=' + new Date().getTime()),
'acai-vue-selectv2': httpVueLoader('https://impulse.webserver2.plandeweb.com/template/estandar/css/builder-acaivueselect-v2.vue?timestamp=' + new Date().getTime()),
// ── Componentes estándar (cocosolution) ──
'acai-vue-colorpicker': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuecolorpicker.vue?timestamp=' + new Date().getTime()),
'acai-vue-upload': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivueupload.vue?timestamp=' + new Date().getTime()),
'acai-vue-records': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuerecords.vue?timestamp=' + new Date().getTime()),
'acai-vue-title': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetitle.vue?timestamp=' + new Date().getTime()),
'acai-vue-wysiwyg': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuewysiwyg.vue?timestamp=' + new Date().getTime()),
'acai-vue-linkv2': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuelinkv2.vue?timestamp=' + new Date().getTime()),
'acai-vue-textbox': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetextbox.vue?timestamp=' + new Date().getTime()),
'acai-vue-textfield': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetextfield.vue?timestamp=' + new Date().getTime()),
'acai-vue-datepicker': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuedatepicker.vue?timestamp=' + new Date().getTime()),
```
**IMPORTANTE:** `acai-vue-list` ha sido reemplazado por `acai-vue-selectv2` en todos los VUEs. NO usar `acai-vue-list` en nuevos VUEs.
---
## 7. Mapeo HTML → Vue
| `data-field-type` | Componente |
|---|---|
| `textfield` | `acai-vue-textfield` |
| `headfield` | `acai-vue-title` |
| `wysiwyg` | `acai-vue-wysiwyg` |
| `textbox` | `acai-vue-textbox` |
| `list` | `acai-vue-selectv2` |
| `upload` / `uploadMulti` | `acai-vue-upload` |
| `linkv2` | `acai-vue-linkv2` (siempre con `:show_text="true"`) |
| `multiv2` | `acai-vue-records` |
| `textfield` (usado como fecha) | `acai-vue-datepicker` + `acai-vue-textfield` oculto |
---
## 8. Script base
```javascript
module.exports = {
props: ["active", "section_id"],
data() {
return {
data: null,
builder: null,
idiomas: IDIOMAS,
tabsConfig: [ /* tabs */ ],
// iconos para toggles (solo si hay campos de 2 opciones con iconos)
// iconosNombreCampo: { '': '<svg>...</svg>', '1': '<svg>...</svg>' }
};
},
components: { /* solo los usados */ },
mounted() { this.$emit("child-mounted"); },
methods: { saveData() { this.$emit("save-data"); } },
};
```
---
## 9. Decisión de tabs según contenido HTML y contexto semántico
### 9.1 Organización contextual (PRIORITARIA)
**IMPORTANTE:** Primero analizar el **nombre del campo** para determinar su contexto semántico, independientemente del tipo. Un campo `list` llamado "tipo de imagen" debe ir en el tab **Imágenes**, no en Configuración.
#### Keywords para tab Imágenes:
Campos que contengan: `imagen`, `photo`, `video`, `fondo`, `background`, `logo`, `icono`, `icon`
**Ejemplos:**
- ✅ "tipo de imagen" (list) → **Imágenes**
- ✅ "video de fondo" (list) → **Imágenes**
- ✅ "logo principal" (upload) → **Imágenes**
#### Keywords para tab Enlaces:
Campos que contengan: `enlace`, `link`, `boton`, `button`, `url`, `href`
**Ejemplos:**
- ✅ "texto del botón" (textfield) → **Enlaces**
- ✅ "url externa" (textfield) → **Enlaces**
- ✅ "estilo del enlace" (list) → **Enlaces**
#### Keywords para tab Textos:
Campos que contengan: `titulo`, `title`, `texto`, `text`, `descripcion`, `description`, `contenido`, `content`, `label`, `etiqueta`
**Ejemplos:**
- ✅ "título principal" (headfield) → **Textos**
- ✅ "descripción corta" (textfield) → **Textos**
### 9.2 Organización por tipo (fallback)
Si el nombre del campo **no** coincide con ninguna keyword, usar el tipo:
| Tipo | Tab |
|---|---|
| `headfield`, `textfield`, `textbox`, `wysiwyg` | Textos |
| `upload`, `image` | Imágenes |
| `linkv2` | Enlaces |
| `list`, `select` (sin contexto) | Configuración |
| `multiv2` (records) | Bloques |
| Otros campos de configuración | Configuración |
---
## 10. Reglas especiales
### 10.1 Selector imagen/video con v-show
Cuando el HTML tenga un campo `list` con opciones tipo `"|Imagen,1|Video"`:
- El selector "Tipo de fondo" va en el tab **Imágenes** como **primer campo** (encima de los uploads).
- Se renderiza como toggle con iconos (foto/vídeo) usando `acai-vue-selectv2` con `:toggle-icons`.
- Usa el icono `icon-tabler-photo-video`:
```html
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-photo-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15h-3a3 3 0 0 1 -3 -3v-6a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v3" /><path d="M9 12a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3l0 -6" /><path d="M3 12l2.296 -2.296a2.41 2.41 0 0 1 3.408 0l.296 .296" /><path d="M14 13.5v3l2.5 -1.5l-2.5 -1.5" /><path d="M7 6v.01" /></svg>
```
- El upload de **imágenes** lleva: `v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == ''"` (visible cuando es imagen o vacío).
- El upload de **video** lleva: `v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == '1'"` (visible cuando es video).
- **NUNCA quitar estos `v-show`**, son esenciales para mostrar uno u otro según la selección.
- Iconos del toggle:
```javascript
iconosTipoImagen: {
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-photo"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" /><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3" /></svg>',
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4z" /><path d="M3 6m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" /></svg>'
}
```
### 10.2 Grupo overlay (tipo + color + opacidad)
Cuando el HTML contenga campos de overlay (tipo de overlay, color del overlay, opacidad del overlay):
- Los tres campos van **juntos** en el tab **Imágenes**, **debajo** de la imagen/video sobre la que se aplica el overlay.
- El orden es: tipo de overlay → color del overlay (colorpicker) → opacidad del overlay.
- El **color del overlay NO va en el tab Colores**, va en Imágenes junto al resto del grupo overlay.
- El tipo de overlay (2 opciones: Sin degradado / Con degradado) se renderiza como toggle con iconos:
```javascript
iconosOverlay: {
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-square"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /></svg>',
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-gradient"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M7 3v18" /><path d="M3 14h4" /><path d="M3 10h4" /><path d="M3 6h4" /><path d="M3 18h4" /></svg>'
}
```
### 10.3 Campos que afectan al enlace
Los campos `list` que modifican propiedades del botón de enlace (radio borde, estilo, etc.) van en el tab **Enlaces**, debajo del campo `linkv2` al que afectan. NO van en Configuración ni en Imágenes.
### 10.4 Tabs base: definición fija de id, label, color e icono
Los tabs base siempre usan la siguiente definición fija. Este es el orden por defecto; solo se incluyen los tabs que el módulo necesite. Tabs adicionales (ej: "Formulario", "Video") se crean con id, label, color e icono nuevos.
```javascript
{ id: "configuracion", label: "Configuración", color: "#f59e0b", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>' },
{ id: "imagenes", label: "Imágenes", color: "#10b981", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M11 20h-4a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v4" /><path d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l3 3" /><path d="M14 14l1 -1c.31 -.298 .644 -.497 .987 -.596" /><path d="M18.42 15.61a2.1 2.1 0 0 1 2.97 2.97l-3.39 3.42h-3v-3l3.42 -3.39" /></svg>' },
{ id: "textos", label: "Textos", color: "#3b82f6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 10h-14" /><path d="M5 6h14" /><path d="M14 14h-9" /><path d="M5 18h6" /><path d="M18 15v6" /><path d="M15 18h6" /></svg>' },
{ id: "bloques", label: "Bloques", color: "#ec4899", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 4l-8 4l8 4l8 -4l-8 -4" /><path d="M4 12l8 4l8 -4" /><path d="M4 16l8 4l8 -4" /></svg>' },
{ id: "enlaces", label: "Enlaces", color: "#ef4444", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15l6 -6"/><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"/><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"/></svg>' },
{ id: "colores", label: "Colores", color: "#8b5cf6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" /><path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" /><path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" /><path d="M17 17l0 .01" /></svg>' },
```
### 10.5 Campos globales que afectan al multi van DENTRO del tab Bloques
Los campos `list` o `textfield` generales (no de records) que afectan visualmente a los elementos del multi (ej: radio de borde de los bloques, alineación del texto de los bloques, diseño del enlace de los bloques) deben colocarse **dentro del tab Bloques**, en la zona **superior**, ANTES del bloque descriptivo "Bloques del multi" y del `<acai-vue-records>`. Estos campos NO van en Configuración ni en otros tabs, ya que pertenecen conceptualmente a los bloques.
### 10.6 Bloque descriptivo "Bloques del multi" antes de acai-vue-records
Siempre añadir un bloque descriptivo con el icono `icon-tabler-stack-2` y el texto "Bloques del multi : Personaliza los bloques del multi." justo antes de `<acai-vue-records>`:
```html
<!-- Multi -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-stack-2"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 4l-8 4l8 4l8 -4l-8 -4" /><path d="M4 12l8 4l8 -4" /><path d="M4 16l8 4l8 -4" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Bloques del multi :</b> Personaliza los bloques del multi.</p>
</div>
</div>
</div>
```
### 10.7 Slot de acai-vue-records: NO desestructurar `color`
El slot de `<acai-vue-records>` NUNCA debe desestructurar `color`. Siempre usar:
```html
<template v-slot="{ record, index }">
```
**NUNCA** usar:
```html
<template v-slot="{ record, color, index }">
```
De esta forma, `color` dentro del multi resuelve al `color` del tab padre (`<template #bloques="{ color }">`), y los iconos SVG con `:style="{ color: color }"` siempre mostrarán el color correcto del tab.
### 10.8 Estructura del componente acai-vue-records
El componente `<acai-vue-records>` siempre debe incluir todas estas props y atributos:
```html
<acai-vue-records :data="data" :builder="builder" :active="active" :section_id="section_id" :root_builder_vue="$parent" ref="recordsNode">
<template v-slot="{ record, index }">
<!-- campos del record -->
</template>
</acai-vue-records>
```
**NUNCA** usar una versión simplificada sin `:active`, `:section_id`, `:root_builder_vue` o `ref`.
### 10.9 Orden del campo "Color título resaltado" en tab Colores
Cuando el módulo tenga un campo de **Color título resaltado** (tipo `list` con opciones Main color / Main color light / Main color dark), este campo debe colocarse **inmediatamente debajo** del campo **Color del título** en el tab **Colores**. Nunca en el tab Textos ni en otra posición del tab Colores.
---
## 11. Consistencia de iconos y textos descriptivos entre VUEs
### 11.1 Iconos
Los campos que ya tienen un icono SVG asignado en VUEs anteriores deben usar SIEMPRE ese mismo icono en todos los VUEs futuros. Solo se crean o personalizan iconos nuevos para campos que no se hayan visto antes en ningún VUE previo.
### 11.2 Textos descriptivos
Los textos descriptivos (título en negrita + descripción + nota) de campos recurrentes (pretítulo, título, subtítulo, texto largo, enlace, color de fondo, color del texto, etc.) deben ser idénticos en todos los VUEs. Solo se modifican si el HTML del módulo revela un comportamiento diferente para ese campo concreto.
### 11.3 Registro de referencia
Usar como referencia los iconos y textos del primer VUE en que apareció cada tipo de campo. Ante cualquier duda, mantener consistencia con lo ya establecido.
---
## 12. Componente acai-vue-selectv2 (reemplazo de acai-vue-list)
### 12.1 Descripción general
`acai-vue-selectv2` reemplaza completamente a `acai-vue-list`. Es un componente inteligente que detecta automáticamente cómo renderizar según el número y tipo de opciones:
- **2 opciones** → modo **toggle** (pill deslizante con animación)
- **2+ opciones con nombres de color** → modo **color selector** (dropdown con swatches)
- **3+ opciones normales** → modo **select** (dropdown estándar con vue-select)
### 12.2 Props
```html
<acai-vue-selectv2
:builder="builder"
:data="data"
:field="'nombrecampo'"
:toggle-icons="iconosObjeto" <!-- opcional, solo para toggles con iconos -->
@save-data="saveData">
</acai-vue-selectv2>
```
### 12.3 Toggle con iconos (`:toggle-icons`)
Para campos de 2 opciones donde se quieran iconos visuales en el toggle, se pasa un objeto con las claves correspondientes a los valores de las opciones:
```javascript
iconosNombreCampo: {
'': '<svg>...</svg>', // icono para la primera opción (valor vacío)
'1': '<svg>...</svg>' // icono para la segunda opción
}
```
Iconos de toggle establecidos:
- **Lado texto (2 opciones: Izquierda/Derecha):** `icon-tabler-align-box-left-middle` / `icon-tabler-align-box-right-middle`
- **Ver sombra (No/Si):** `icon-tabler-x` / `icon-tabler-check`
- **Tipo imagen (Imagen/Video):** `icon-tabler-photo` / `icon-tabler-video`
- **Tipo overlay (Sin degradado/Con degradado):** `icon-tabler-square` / `icon-tabler-gradient`
### 12.4 Modo color automático
El componente detecta automáticamente si las opciones son colores cuando al menos la mitad de las labels coinciden con:
- Nombres del mapa interno: main color, blanco, negro, gris, gris claro, gris oscuro, gris calido, rojo, azul, verde, etc. (español e inglés)
- Códigos hex (#fff, #ff0000)
- Valores rgb/rgba
- Valores hsl/hsla
Los colores main color, main color light y main color dark se resuelven consultando la configuración del CMS en tiempo real.
### 12.5 Campos de 3+ opciones sin iconos
No necesitan `:toggle-icons`. Se renderizan como dropdown estándar:
```html
<acai-vue-selectv2 :builder="builder" :data="data" :field="'container'" @save-data="saveData"></acai-vue-selectv2>
```
---
## 13. Componente acai-vue-datepicker (campos de fecha)
### 13.1 Uso
Cuando un campo `textfield` en el HTML se usa para fechas (se identifica por el label "Fecha" o similar), se usa `acai-vue-datepicker` junto con un `acai-vue-textfield` oculto:
```html
<div class="relative mt-2">
<acai-vue-datepicker :builder="builder" :data="data" :field="'fecha'" :label="'Fecha'" @save-data="saveData"></acai-vue-datepicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'fecha'" @save-data="saveData"></acai-vue-textfield>
</div>
```
### 13.2 Notas estándar para datepicker
```html
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> puedes elegir el formato de la fecha en el selector.</p>
<p class="text-xs leading-snug text-gray-500 mt-1 ml-14"><b class="text-gray-700">Recuerda :</b> también puedes mostrar la hora activando el botón del reloj.</p>
```
### 13.3 Icono estándar para fecha
```html
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" :style="{ color: color }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-calendar-week"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12" /><path d="M16 3v4" /><path d="M8 3v4" /><path d="M4 11h16" /><path d="M7 14h.013" /><path d="M10.01 14h.005" /><path d="M13.01 14h.005" /><path d="M16.015 14h.005" /><path d="M13.015 17h.005" /><path d="M7.01 17h.005" /><path d="M10.01 17h.005" /></svg>
```
---
## 14. Reglas de spacing (separación entre elementos)
### 14.1 Separación entre nota/recuerda y componente
- El componente siempre lleva `mt-2` respecto a la nota o recuerda que lo precede.
- Nunca `mt-1` entre nota/recuerda y componente.
### 14.2 Nota y Recuerda juntos
Cuando un campo tiene **Nota** y **Recuerda**:
- **Nota** siempre lleva `mt-2` respecto al bloque de icono+texto anterior.
- **Recuerda** lleva `mt-1` respecto a la Nota (va justo debajo).
- El componente lleva `mt-2` respecto al Recuerda.
Ejemplo:
```html
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> texto de la nota.</p>
<p class="text-xs leading-snug text-gray-500 mt-1 ml-14"><b class="text-gray-700">Recuerda :</b> texto del recuerda.</p>
<div class="relative mt-2 ml-14">
<!-- componente -->
</div>
```
### 14.3 Solo Nota (sin Recuerda)
```html
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> texto.</p>
<div class="relative mt-2 ml-14">
<!-- componente -->
</div>
```
### 14.4 Solo Recuerda (sin Nota)
El Recuerda usa el estilo especial (sin ml-14, con font-light):
```html
<p class="text-xs leading-snug text-gray-600 font-light mt-2"><b class="text-black">Recuerda :</b> texto del recuerda.</p>
<div class="relative mt-2 ml-14">
<!-- componente -->
</div>
```
### 14.5 Sin Nota ni Recuerda
El componente lleva `mt-2` directamente:
```html
<div class="relative mt-2 ml-14">
<!-- componente -->
</div>
```
### 14.6 Separación entre campos
Siempre `mt-6` entre bloques de campo:
```html
<div class="flex w-full items-center mt-6">
```
El primer campo de cada tab NO lleva `mt-6` (no hay campo previo).

View File

@@ -21,11 +21,20 @@ export function registerGetWebUrlTool(server) {
};
}
// En modo local forzamos http:// porque los certificados SSL de
// los subdominios forge pueden no validar correctamente en
// playwright/fetch/curl desde el container. En produccion se
// mantiene https:// (el sitio real tiene certificado valido).
let webUrl = credentials.web_url;
if (credentials.mode !== "production" && typeof webUrl === "string" && webUrl.startsWith("https://")) {
webUrl = "http://" + webUrl.slice("https://".length);
}
return {
content: [{
type: "text",
text: JSON.stringify({
web_url: credentials.web_url,
web_url: webUrl,
api_web_url: credentials.api_web_url || null,
website: credentials.website || null,
note: "Always use web_url for Playwright/fetch. IMPORTANT: Always append ?pruebas=1 to any URL you visit (e.g. web_url + '/?pruebas=1' or web_url + '/servicios/?pruebas=1'). Never use the production domain directly.",

View File

@@ -11,7 +11,7 @@
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp", "--headless", "--executable-path", "/home/appuser/.cache/ms-playwright/chromium-1212/chrome-linux64/chrome"],
"args": ["@playwright/mcp", "--headless", "--isolated", "--executable-path", "/home/appuser/.cache/ms-playwright/chromium-1212/chrome-linux64/chrome"],
"timeout": 30,
"startup_timeout": 15
},

View File

@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from ..config import settings
from ..models.context import MemoryDocument, MemoryType
from ..models.session import SessionState, SessionStatus
from ..orchestrator.engine import OrchestratorEngine
@@ -348,6 +349,15 @@ async def get_context_debug(session_id: str) -> dict[str, Any]:
"last_build": last,
"full_context": full_context,
"history": history,
"budgets": {
"effective_context_budget": settings.effective_context_budget,
"compaction_threshold": settings.effective_compaction_threshold,
"reserve_tokens": settings.reserve_tokens,
"knowledge_base_max_tokens": settings.knowledge_base_max_tokens,
"tool_raw_output_max_chars": settings.tool_raw_output_max_chars,
"task_history_max_entries": settings.task_history_max_entries,
"task_history_max_tokens": settings.task_history_max_tokens,
},
}

View File

@@ -38,10 +38,19 @@ class Settings(BaseSettings):
temperature: float = 0.3
# --- Context engine ---
context_max_tokens: int = 120_000
compaction_threshold_tokens: int = 80_000
model_context_window: int = 0 # 0 = use legacy fixed budget / explicit override
model_max_output_tokens: int = 4096
context_max_tokens: int = 0 # 0 = auto-budget from model window, fallback legacy 120k
compaction_threshold_tokens: int = 0 # 0 = derive from ratio
compaction_threshold_ratio: float = 0.80
context_reserve_ratio: float = 0.10
artifact_summary_max_chars: int = 2000
knowledge_base_max_tokens: int = 30_000
working_context_max_items: int = 20
tool_raw_output_max_chars: int = 2000
conversation_recent_raw_limit: int = 2
task_history_max_entries: int = 20
task_history_max_tokens: int = 1500
# --- MCP ---
mcp_config_path: str = "" # Path to mcp.json; empty = legacy single-server mode
@@ -64,5 +73,32 @@ class Settings(BaseSettings):
model_config = {"env_prefix": "AGENTIC_", "env_file": ".env", "extra": "ignore"}
@property
def reserve_tokens(self) -> int:
if self.model_context_window <= 0:
return 0
return max(0, int(self.model_context_window * self.context_reserve_ratio))
@property
def effective_context_budget(self) -> int:
if self.context_max_tokens > 0:
return self.context_max_tokens
if self.model_context_window > 0:
budget = (
self.model_context_window
- max(0, self.model_max_output_tokens)
- self.reserve_tokens
)
return max(1, budget)
return 120_000
@property
def effective_compaction_threshold(self) -> int:
if self.compaction_threshold_tokens > 0:
return min(self.compaction_threshold_tokens, self.effective_context_budget)
return max(1, int(self.effective_context_budget * self.compaction_threshold_ratio))
settings = Settings()

View File

@@ -7,6 +7,7 @@ while preserving the most important information.
from __future__ import annotations
import hashlib
import json
import logging
import re
from typing import Any
@@ -47,25 +48,41 @@ class ContextCompactor:
# ------------------------------------------------------------------
def compact_sections(
self, sections: list[ContextSection]
) -> list[ContextSection]:
self,
sections: list[ContextSection],
max_tokens: int | None = None,
) -> tuple[list[ContextSection], dict[str, Any]]:
"""Remove redundancy and trim low-priority sections to fit budget."""
budget = max_tokens if max_tokens is not None else self.max_tokens
original_count = len(sections)
# 1. Deduplicate identical content across sections
sections = self._deduplicate(sections)
duplicates_removed = original_count - len(sections)
# 2. Estimate tokens per section
for s in sections:
s.token_estimate = estimate_tokens(s.content)
total = sum(s.token_estimate for s in sections)
if total <= self.max_tokens:
return sections
meta = {
"budget_tokens": budget,
"input_tokens": total,
"output_tokens": total,
"sections_input": original_count,
"sections_output": len(sections),
"duplicates_removed": duplicates_removed,
"sections_compacted": 0,
"sections_removed": 0,
}
if total <= budget:
return sections, meta
# 3. Sort by priority (highest first) — immutable_rules never trimmed
sections.sort(key=lambda s: s.priority, reverse=True)
# 4. Progressively trim lowest-priority sections
while total > self.max_tokens and sections:
while total > budget and sections:
lowest = sections[-1]
if lowest.section_type == ContextSectionType.IMMUTABLE_RULES:
break # Never trim rules
@@ -77,12 +94,16 @@ class ContextCompactor:
lowest.content = compacted
lowest.token_estimate = new_estimate
total -= saved
meta["sections_compacted"] += 1
else:
# Remove the section entirely
total -= lowest.token_estimate
sections.pop()
meta["sections_removed"] += 1
return sections
meta["output_tokens"] = total
meta["sections_output"] = len(sections)
return sections, meta
def summarize_tool_output(
self,
@@ -137,6 +158,140 @@ class ContextCompactor:
break
return "\n".join(lines)
def compact_conversation(
self,
messages: list[dict[str, Any]],
max_tokens: int,
recent_raw_limit: int = 2,
raw_char_limit: int = 2000,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Compact conversation history while preserving the latest user turn."""
total = sum(self._estimate_message_tokens(m) for m in messages)
meta = {
"budget_tokens": max_tokens,
"input_tokens": total,
"output_tokens": total,
"messages_input": len(messages),
"messages_output": len(messages),
"messages_compacted": 0,
"tool_messages_compacted": 0,
"assistant_messages_compacted": 0,
"user_messages_compacted": 0,
"raw_tool_results_kept": 0,
}
if total <= max_tokens:
return messages, meta
compacted = [dict(m) for m in messages]
last_user_idx = max(
(i for i, m in enumerate(compacted) if m.get("role") == "user"),
default=-1,
)
tool_indexes = [i for i, m in enumerate(compacted) if m.get("role") == "tool"]
keep_raw_tool_indexes = (
set(tool_indexes[-recent_raw_limit:])
if recent_raw_limit > 0
else set()
)
for idx in keep_raw_tool_indexes:
content = compacted[idx].get("content", "")
if isinstance(content, str) and content:
truncated = content[:raw_char_limit]
if truncated != content:
compacted[idx]["content"] = truncated
meta["messages_compacted"] += 1
meta["tool_messages_compacted"] += 1
meta["raw_tool_results_kept"] += 1
total = sum(self._estimate_message_tokens(m) for m in compacted)
if total > max_tokens:
for idx in tool_indexes:
if idx in keep_raw_tool_indexes:
continue
content = compacted[idx].get("content", "")
if not isinstance(content, str) or not content:
continue
compacted[idx]["content"] = self._summarize_message_content(
content,
prefix="[TOOL RESULT COMPACTADO]",
max_chars=max(180, raw_char_limit // 4),
)
meta["messages_compacted"] += 1
meta["tool_messages_compacted"] += 1
total = sum(self._estimate_message_tokens(m) for m in compacted)
if total <= max_tokens:
break
if total > max_tokens:
for idx, message in enumerate(compacted):
if idx == last_user_idx or message.get("role") != "assistant":
continue
content = message.get("content", "")
if not isinstance(content, str) or not content:
continue
message["content"] = self._summarize_message_content(
content,
prefix="[ASSISTANT COMPACTADO]",
max_chars=max(240, raw_char_limit // 3),
)
meta["messages_compacted"] += 1
meta["assistant_messages_compacted"] += 1
total = sum(self._estimate_message_tokens(m) for m in compacted)
if total <= max_tokens:
break
if total > max_tokens:
for idx, message in enumerate(compacted):
if idx == last_user_idx or message.get("role") != "user":
continue
content = message.get("content", "")
if not isinstance(content, str) or not content:
continue
message["content"] = self._summarize_message_content(
content,
prefix="[USER CONTEXT COMPACTADO]",
max_chars=max(220, raw_char_limit // 3),
)
meta["messages_compacted"] += 1
meta["user_messages_compacted"] += 1
total = sum(self._estimate_message_tokens(m) for m in compacted)
if total <= max_tokens:
break
if total > max_tokens:
for idx in tool_indexes:
if idx in keep_raw_tool_indexes:
compacted[idx]["content"] = self._summarize_message_content(
compacted[idx].get("content", ""),
prefix="[TOOL RESULT COMPACTADO]",
max_chars=max(180, raw_char_limit // 5),
)
total = sum(self._estimate_message_tokens(m) for m in compacted)
if total <= max_tokens:
break
if total > max_tokens:
for idx, message in enumerate(compacted):
if idx == last_user_idx:
continue
role = message.get("role", "")
content = message.get("content", "")
if not isinstance(content, str) or not content:
continue
if role == "tool":
message["content"] = "[TOOL RESULT COMPACTADO]"
elif role == "assistant":
message["content"] = "[ASSISTANT COMPACTADO]"
elif role == "user":
message["content"] = "[USER CONTEXT COMPACTADO]"
total = sum(self._estimate_message_tokens(m) for m in compacted)
if total <= max_tokens:
break
meta["output_tokens"] = total
return compacted, meta
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
@@ -166,6 +321,45 @@ class ContextCompactor:
compacted.append(line)
return "\n".join(compacted)
def _summarize_message_content(
self,
content: str,
prefix: str,
max_chars: int,
) -> str:
stripped = content.strip()
compacted = self._compact_text(content)
if len(compacted) <= max_chars:
if compacted != stripped:
summary = f"{prefix} {compacted}".strip()
if len(summary) > max_chars:
summary = summary[:max_chars].rstrip() + ""
return summary
return compacted
lines = [l.strip() for l in compacted.splitlines() if l.strip()]
if not lines:
return prefix
if len(lines) == 1:
return f"{prefix} {lines[0][:max_chars]}".strip()
first = lines[0][: max_chars // 2]
last = lines[-1][: max_chars // 3]
summary = f"{prefix} First: {first}"
if last and last != first:
summary += f" | Last: {last}"
if len(summary) > max_chars:
summary = summary[:max_chars].rstrip() + ""
return summary
@staticmethod
def _estimate_message_tokens(message: dict[str, Any]) -> int:
content = message.get("content", "")
tokens = estimate_tokens(content if isinstance(content, str) else str(content))
if message.get("tool_calls"):
tokens += estimate_tokens(json.dumps(message.get("tool_calls", []), ensure_ascii=False))
return tokens
def _extract_facts(self, raw_output: str) -> list[str]:
"""Extract short factual claims from tool output."""
facts: list[str] = []

View File

@@ -45,7 +45,7 @@ class ContextEngine:
memory_store: MemoryStore | None = None,
) -> None:
self.compactor = compactor or ContextCompactor(
max_tokens=settings.context_max_tokens
max_tokens=settings.effective_context_budget
)
self.memory = memory_store
self._embed_service: EmbeddingService | None = None
@@ -85,32 +85,127 @@ class ContextEngine:
if "project_profile" in allowed:
sections.append(self._build_project_profile(session))
include_artifact_memory = (
artifacts
and ("artifact_memory" in allowed or "task_state" in allowed)
)
# 3. Knowledge base — loaded from memory store
if "knowledge_base" in allowed and self.memory:
kb_section = await self._build_knowledge_base(session)
kb_section = await self._build_knowledge_base(
session,
max_tokens=settings.knowledge_base_max_tokens,
)
if kb_section:
sections.append(kb_section)
base_user_content, resolved_followup_context, user_content, followup_mode = (
self._resolve_current_request(session)
)
session.metadata["followup_mode"] = followup_mode
# 4. Task history — compact summaries of past tasks in this session
if "task_state" in allowed and session.task_history:
sections.append(self._build_task_history(session))
# 5. Task state — current task (includes compacted previous steps)
if "task_state" in allowed and session.current_task:
sections.append(self._build_task_state(session.current_task))
sections.append(
self._build_task_state(
session.current_task,
objective_override=base_user_content,
resolved_context=resolved_followup_context,
followup_mode=followup_mode,
)
)
# Compact to fit budget
sections = self.compactor.compact_sections(sections)
# 6. Artifact memory — summaries for recent/current artifacts
if include_artifact_memory:
context_artifacts = self._select_context_artifacts(session, artifacts or [])
if context_artifacts:
sections.append(self._build_artifact_memory(context_artifacts))
# Assemble system prompt from sections
system_prompt = self._assemble_system_prompt(sections)
# Build messages with real conversation history
messages = self._build_messages(session, conversation)
total_tokens = estimate_tokens(system_prompt) + sum(
estimate_tokens(m.get("content", "")) for m in messages
# Build messages with real conversation history first so sections can
# compact against the remaining budget.
messages = self._build_messages(
session,
conversation,
user_content=user_content,
)
raw_message_tokens = sum(self._estimate_message_tokens(m) for m in messages)
pre_compaction_section_tokens = sum(estimate_tokens(s.content) for s in sections)
pre_compaction_total = pre_compaction_section_tokens + raw_message_tokens
section_budget = max(1, settings.effective_context_budget - raw_message_tokens)
# Compact sections only when the full prompt is approaching the target.
section_compaction = {
"budget_tokens": section_budget,
"input_tokens": pre_compaction_section_tokens,
"output_tokens": pre_compaction_section_tokens,
"sections_input": len(sections),
"sections_output": len(sections),
"duplicates_removed": 0,
"sections_compacted": 0,
"sections_removed": 0,
}
system_prompt = self._assemble_system_prompt(sections)
system_prompt_tokens = estimate_tokens(system_prompt)
hard_message_budget = max(1, settings.effective_context_budget - system_prompt_tokens)
target_message_budget = max(1, settings.effective_compaction_threshold - system_prompt_tokens)
message_budget = min(hard_message_budget, target_message_budget)
conversation_compaction = {
"budget_tokens": message_budget,
"hard_budget_tokens": hard_message_budget,
"input_tokens": raw_message_tokens,
"output_tokens": raw_message_tokens,
"messages_input": len(messages),
"messages_output": len(messages),
"messages_compacted": 0,
"raw_tool_results_kept": 0,
}
total_tokens = system_prompt_tokens + raw_message_tokens
if total_tokens > settings.effective_compaction_threshold:
messages, conversation_compaction = self.compactor.compact_conversation(
messages,
max_tokens=message_budget,
recent_raw_limit=settings.conversation_recent_raw_limit,
raw_char_limit=settings.tool_raw_output_max_chars,
)
total_tokens = system_prompt_tokens + sum(
self._estimate_message_tokens(m) for m in messages
)
if total_tokens > settings.effective_context_budget:
section_budget = max(
1,
settings.effective_context_budget
- sum(self._estimate_message_tokens(m) for m in messages),
)
sections, section_compaction = self.compactor.compact_sections(
sections,
max_tokens=section_budget,
)
system_prompt = self._assemble_system_prompt(sections)
system_prompt_tokens = estimate_tokens(system_prompt)
total_tokens = system_prompt_tokens + sum(
self._estimate_message_tokens(m) for m in messages
)
if total_tokens > settings.effective_context_budget:
hard_message_budget = max(
1,
settings.effective_context_budget - system_prompt_tokens,
)
messages, conversation_compaction = self.compactor.compact_conversation(
messages,
max_tokens=hard_message_budget,
recent_raw_limit=settings.conversation_recent_raw_limit,
raw_char_limit=settings.tool_raw_output_max_chars,
)
total_tokens = system_prompt_tokens + sum(
self._estimate_message_tokens(m) for m in messages
)
package = ContextPackage(
sections=sections,
@@ -124,6 +219,8 @@ class ContextEngine:
"system_prompt": system_prompt,
"messages": messages,
"total_tokens": total_tokens,
"budget_tokens": settings.effective_context_budget,
"threshold_tokens": settings.effective_compaction_threshold,
"timestamp": time.time(),
}
@@ -146,11 +243,27 @@ class ContextEngine:
"total_tokens": total_tokens,
"sections": section_summary,
"sections_count": len(sections),
"compacted": len(sections) < len(allowed),
"system_prompt_tokens": estimate_tokens(system_prompt),
"user_message_preview": messages[0]["content"][:200] if messages else "",
"compacted": bool(
section_compaction.get("sections_compacted")
or section_compaction.get("sections_removed")
or section_compaction.get("duplicates_removed")
or conversation_compaction.get("messages_compacted")
),
"system_prompt_tokens": system_prompt_tokens,
"user_message_preview": user_content[:200],
"artifacts_count": len(artifacts) if artifacts else 0,
"conversation_messages": conv_len,
"budget_tokens": settings.effective_context_budget,
"threshold_tokens": settings.effective_compaction_threshold,
"message_tokens": conversation_compaction.get("output_tokens", raw_message_tokens),
"message_tokens_before_compaction": raw_message_tokens,
"pre_compaction_tokens": pre_compaction_total,
"post_compaction_tokens": total_tokens,
"section_budget_tokens": section_budget,
"message_budget_tokens": message_budget,
"section_compaction": section_compaction,
"conversation_compaction": conversation_compaction,
"over_budget": total_tokens > settings.effective_context_budget,
}
history = self._history[session.session_id]
@@ -273,7 +386,9 @@ class ContextEngine:
)
async def _build_knowledge_base(
self, session: SessionState
self,
session: SessionState,
max_tokens: int,
) -> ContextSection | None:
"""Load relevant knowledge documents via semantic search.
@@ -314,8 +429,7 @@ class ContextEngine:
]
# Include ALL docs — 42K tokens fits well within model context (128K)
max_kb_tokens = 50_000
token_budget = max_kb_tokens
token_budget = max_tokens
full_docs: list[MemoryDocument] = []
for doc_id in ranked_ids:
@@ -429,24 +543,78 @@ class ContextEngine:
review = entry.get("review", "")
if review:
lines.append(f" Review: {review[:100]}")
outcomes = entry.get("outcomes", [])
if outcomes:
lines.append(f" Outcomes: {'; '.join(outcomes[:2])}")
focus_refs = entry.get("focus_refs", [])
if focus_refs:
ref_parts = []
for ref in focus_refs[:3]:
label = ref.get("label", "")
ref_type = ref.get("type", "entity")
ref_id = ref.get("id", "")
if ref_id:
ref_parts.append(f"{ref_type} '{label}' ({ref_id})")
else:
ref_parts.append(f"{ref_type} '{label}'")
if ref_parts:
lines.append(f" Focus refs: {'; '.join(ref_parts)}")
lines.append("")
content = "\n".join(lines)
return ContextSection(
section_type=ContextSectionType.TASK_STATE,
section_type=ContextSectionType.TASK_HISTORY,
content=content,
priority=55, # Below knowledge (60), above artifacts (50)
token_estimate=estimate_tokens(content),
)
def _build_task_state(self, task: TaskState) -> ContextSection:
def _build_task_state(
self,
task: TaskState,
objective_override: str | None = None,
resolved_context: str = "",
followup_mode: str = "none",
) -> ContextSection:
lines = [
"# Current Task",
f"**Objective**: {task.objective}",
f"**Objective**: {objective_override or task.objective}",
f"**Status**: {task.status}",
f"**Step**: {task.current_step_index + 1}/{len(task.plan)}",
]
if followup_mode != "none":
lines.append(f"**Follow-up Mode**: {followup_mode}")
if resolved_context:
lines.extend(
[
"",
"## Resolved Follow-up Context",
resolved_context,
]
)
if followup_mode == "transform":
lines.extend(
[
"",
"## Follow-up Policy",
"- Reutiliza primero el trabajo y contexto ya reunidos.",
"- No llames herramientas salvo que falte un dato factual critico para responder.",
"- Prioriza transformar, refinar o reescribir lo ya analizado.",
]
)
elif followup_mode == "fetch_more":
lines.extend(
[
"",
"## Follow-up Policy",
"- El usuario esta pidiendo datos o verificacion adicional.",
"- Puedes usar herramientas si aportan informacion nueva y necesaria.",
]
)
current = task.current_step()
if current:
lines.extend(
@@ -523,7 +691,9 @@ class ContextEngine:
ContextSectionType.IMMUTABLE_RULES,
ContextSectionType.PROJECT_PROFILE,
ContextSectionType.KNOWLEDGE_BASE,
ContextSectionType.TASK_HISTORY,
ContextSectionType.TASK_STATE,
ContextSectionType.ARTIFACT_MEMORY,
]
section_map: dict[ContextSectionType, ContextSection] = {
s.section_type: s for s in sections
@@ -537,28 +707,27 @@ class ContextEngine:
self,
session: SessionState,
conversation: list[dict[str, Any]] | None = None,
user_content: str | None = None,
) -> list[dict[str, Any]]:
"""Build the messages array with real conversation history.
Includes the user objective message followed by the full
assistant/tool conversation — like professional agentic tools.
"""
if session.current_task:
step = session.current_task.current_step()
if step:
user_content = (
f"Execute this step: {step.description}\n"
f"Overall objective: {session.current_task.objective}"
)
else:
user_content = session.current_task.objective
else:
user_content = "Awaiting task assignment."
if user_content is None:
_, _, user_content, _ = self._resolve_current_request(session)
messages: list[dict[str, Any]] = []
# Include previous task exchanges as compact conversation history
if session.task_history:
recent_messages = self._sanitize_recent_messages(
getattr(session, "recent_messages", []),
)
if recent_messages:
messages.extend(recent_messages)
# Include previous task exchanges as compact conversation history only
# when there is no raw recent conversation window available.
if session.task_history and not recent_messages:
history_lines = ["[HISTORIAL DE CONVERSACIÓN ANTERIOR — NO ejecutar de nuevo, solo contexto]"]
for entry in session.task_history[-10:]:
objective = entry.get("objective", "")[:200]
@@ -579,6 +748,22 @@ class ContextEngine:
kd_parts.append(f"modules: {key_data['modules'][:5]}")
if kd_parts:
history_lines.append(f" Datos clave: {'; '.join(kd_parts)}")
outcomes = entry.get("outcomes", [])
if outcomes:
history_lines.append(f" Conclusiones: {'; '.join(outcomes[:2])}")
focus_refs = entry.get("focus_refs", [])
if focus_refs:
focus_parts = []
for ref in focus_refs[:3]:
label = ref.get("label", "")
ref_type = ref.get("type", "entity")
ref_id = ref.get("id", "")
if ref_id:
focus_parts.append(f"{ref_type}:{label} ({ref_id})")
else:
focus_parts.append(f"{ref_type}:{label}")
if focus_parts:
history_lines.append(f" Referencias activas: {'; '.join(focus_parts)}")
# Extract agent response from summary
if " → Agent: " in summary:
agent_part = summary.split(" → Agent: ", 1)[1][:200]
@@ -596,3 +781,213 @@ class ContextEngine:
messages.extend(conversation)
return messages
def _resolve_current_request(self, session: SessionState) -> tuple[str, str, str, str]:
if session.current_task:
step = session.current_task.current_step()
if step:
base_user_content = (
f"Execute this step: {step.description}\n"
f"Overall objective: {session.current_task.objective}"
)
else:
base_user_content = session.current_task.objective
else:
base_user_content = "Awaiting task assignment."
followup_mode = self._classify_followup_mode(base_user_content)
resolved_context = ""
if session.task_history and followup_mode != "none":
resolved_context = self._build_followup_resolution(session.task_history[-1])
if not resolved_context and followup_mode != "none":
resolved_context = self._build_recent_message_resolution(
getattr(session, "recent_messages", []),
)
if resolved_context:
user_content = (
"[CONTEXTO RESUELTO DEL TURNO ANTERIOR]\n"
f"{resolved_context}\n\n"
f"{base_user_content}"
)
else:
user_content = base_user_content
return base_user_content, resolved_context, user_content, followup_mode
@staticmethod
def _sanitize_recent_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
sanitized_messages: list[dict[str, Any]] = []
for message in messages:
role = str(message.get("role", "")).strip()
if role not in {"user", "assistant", "tool"}:
continue
sanitized: dict[str, Any] = {"role": role}
content = message.get("content", "")
if isinstance(content, str) and content:
sanitized["content"] = content
if role == "assistant":
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
sanitized["tool_calls"] = tool_calls
if role == "tool":
tool_call_id = str(message.get("tool_call_id", "")).strip()
if tool_call_id:
sanitized["tool_call_id"] = tool_call_id
if "content" not in sanitized and "tool_calls" not in sanitized:
continue
sanitized_messages.append(sanitized)
return sanitized_messages
@staticmethod
def _estimate_message_tokens(message: dict[str, Any]) -> int:
content = message.get("content", "")
if isinstance(content, str):
return estimate_tokens(content)
return estimate_tokens(str(content))
@staticmethod
def _looks_like_followup(text: str) -> bool:
lower = text.lower()
followup_markers = (
"ese ",
"esa ",
"eso",
"este ",
"esta ",
"anterior",
"anteriormente",
"mismo",
"hazlo",
"rehaz",
"reescribe",
"céntrate",
"centrate",
"solo en",
)
return len(lower) <= 300 and any(marker in lower for marker in followup_markers)
@classmethod
def _classify_followup_mode(cls, text: str) -> str:
lower = text.lower().strip()
transform_markers = (
"más comercial",
"mas comercial",
"segunda versión",
"segunda version",
"otra versión",
"otra version",
"versión final",
"version final",
"copy",
"estructura",
"lista para aplicar",
"resúm",
"resum",
"rehaz",
"reescribe",
"adapta",
"cámbialo",
"cambialo",
"sin cambiar el foco",
"más técnico",
"mas tecnico",
"más corto",
"mas corto",
"más directo",
"mas directo",
)
fetch_markers = (
"revisa",
"revisa la configuración",
"revisa la configuracion",
"comprueba",
"mira si",
"abre",
"busca",
"localiza",
"consulta",
"verifica",
"comprueba cómo",
"comprueba como",
"cómo está",
"como está",
"como esta",
"qué ves",
"que ves",
)
if any(marker in lower for marker in transform_markers):
return "transform"
if any(marker in lower for marker in fetch_markers):
return "fetch_more"
if not cls._looks_like_followup(text):
return "none"
return "ambiguous"
@staticmethod
def _build_followup_resolution(entry: dict[str, Any]) -> str:
lines: list[str] = []
focus_refs = entry.get("focus_refs", [])
outcomes = entry.get("outcomes", [])
primary = [ref for ref in focus_refs if ref.get("role") == "primary_focus"]
refs_to_render = primary or focus_refs[:3]
if refs_to_render:
rendered = []
for ref in refs_to_render[:3]:
label = ref.get("label", "")
ref_type = ref.get("type", "entity")
ref_id = ref.get("id", "")
if ref_id:
rendered.append(f"- Active ref: {ref_type} '{label}' ({ref_id})")
else:
rendered.append(f"- Active ref: {ref_type} '{label}'")
lines.extend(rendered)
if outcomes:
for outcome in outcomes[:2]:
lines.append(f"- Prior conclusion: {outcome}")
return "\n".join(lines).strip()
@staticmethod
def _build_recent_message_resolution(messages: list[dict[str, Any]]) -> str:
for message in reversed(messages):
if message.get("role") != "assistant":
continue
content = message.get("content", "")
if not isinstance(content, str):
continue
content = " ".join(content.split()).strip()
if not content:
continue
return f"- Recent assistant conclusion: {content[:280]}"
return ""
@staticmethod
def _select_context_artifacts(
session: SessionState,
artifacts: list[ArtifactSummary],
) -> list[ArtifactSummary]:
if not artifacts:
return []
current_task_id = session.current_task.task_id if session.current_task else ""
recent_task_ids = {
entry.get("task_id", "")
for entry in session.task_history[-2:]
if entry.get("task_id")
}
selected: list[ArtifactSummary] = []
for artifact in sorted(artifacts, key=lambda a: a.created_at):
if current_task_id and artifact.task_id == current_task_id:
selected.append(artifact)
elif artifact.task_id in recent_task_ids:
selected.append(artifact)
return selected[-settings.working_context_max_items:]

View File

@@ -13,6 +13,7 @@ class ContextSectionType(StrEnum):
IMMUTABLE_RULES = "immutable_rules"
PROJECT_PROFILE = "project_profile"
KNOWLEDGE_BASE = "knowledge_base"
TASK_HISTORY = "task_history"
TASK_STATE = "task_state"
ARTIFACT_MEMORY = "artifact_memory"
WORKING_CONTEXT = "working_context"

View File

@@ -88,6 +88,7 @@ class SessionState(BaseModel):
current_task: TaskState | None = None
completed_tasks: list[str] = Field(default_factory=list)
task_history: list[dict[str, Any]] = Field(default_factory=list) # Compact summaries of past tasks
recent_messages: list[dict[str, Any]] = Field(default_factory=list) # Rolling raw conversation window across tasks
turn_count: int = 0
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

View File

@@ -10,6 +10,7 @@ import uuid
from typing import Any, AsyncIterator
from ...adapters.base import ModelAdapter, ModelConfig, StreamChunk
from ...config import settings
from ...context.engine import ContextEngine
from ...mcp.manager import MCPManager
from ...memory.store import MemoryStore
@@ -76,7 +77,9 @@ class BaseAgent:
)
# Prepare tool definitions
tool_defs = self._get_allowed_tools()
tool_defs = self._get_allowed_tools(
followup_mode=str(session.metadata.get("followup_mode", "none")),
)
# Stream model response
config = ModelConfig(
@@ -202,7 +205,10 @@ class BaseAgent:
conversation.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": f"[DUPLICADO] Ya ejecutada con mismos argumentos. Resultado: {prev_exec.raw_output[:2000]}",
"content": (
"[DUPLICADO] Ya ejecutada con mismos argumentos. Resultado: "
f"{prev_exec.raw_output[:settings.tool_raw_output_max_chars]}"
),
})
logger.warning("Duplicate tool call skipped: %s (fingerprint: %s)", tc["name"], fp[:8])
continue
@@ -221,7 +227,11 @@ class BaseAgent:
conversation.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": tool_exec.raw_output[:8000] if tool_exec.raw_output else tool_exec.result_summary,
"content": (
tool_exec.raw_output[:settings.tool_raw_output_max_chars]
if tool_exec.raw_output
else tool_exec.result_summary
),
})
# Loop detection: if ALL tool calls in this step were duplicates
@@ -254,6 +264,7 @@ class BaseAgent:
"content": accumulated_content,
"artifacts": artifacts,
"tool_executions": tool_executions,
"conversation": conversation,
"usage": {
"input_tokens": total_input_tokens,
"output_tokens": total_output_tokens,
@@ -304,7 +315,7 @@ class BaseAgent:
tool_exec.status = ToolExecutionStatus.COMPLETED
tool_exec.result_summary = artifact.summary
tool_exec.raw_output = raw_output[:8000]
tool_exec.raw_output = raw_output[:settings.tool_raw_output_max_chars]
tool_exec.duration_ms = duration
await self.sse.emit(
@@ -313,7 +324,7 @@ class BaseAgent:
"tool": tool_name,
"status": "completed",
"summary": artifact.summary[:200],
"raw_output": raw_output[:4000],
"raw_output": raw_output[:min(4000, settings.tool_raw_output_max_chars)],
"tool_call_id": tool_call_id,
},
session_id=session.session_id,
@@ -333,8 +344,10 @@ class BaseAgent:
return tool_exec
def _get_allowed_tools(self) -> list[dict[str, Any]]:
def _get_allowed_tools(self, followup_mode: str = "none") -> list[dict[str, Any]]:
"""Return tool definitions filtered by this agent's allowed_tools."""
if followup_mode == "transform":
return []
if not self.mcp.is_running:
return []
all_tools = self.mcp.get_tool_definitions()

View File

@@ -8,11 +8,13 @@ from __future__ import annotations
import asyncio
import logging
import re
from typing import Any
from ..adapters.base import ModelAdapter
from ..config import settings
from ..context.engine import ContextEngine
from ..context.compactor import estimate_tokens
from ..mcp.manager import MCPManager
from ..memory.store import MemoryStore
from ..models.agent import AgentProfile
@@ -131,22 +133,25 @@ class OrchestratorEngine:
content = result.get("content", "")
usage = result.get("usage", {"input_tokens": 0, "output_tokens": 0})
key_data = self._extract_key_data_from_results([result])
session.recent_messages = self._append_recent_messages(
session.recent_messages,
message=message,
conversation=result.get("conversation", []),
)
session.task_history.append({
"task_id": task.task_id,
"objective": message,
"agent_id": session.agent_id,
"status": "completed",
"steps": 1,
"facts": task.facts_extracted[-10:],
"key_data": key_data,
"tools_used": [te.tool_name for te in result.get("tool_executions", [])],
"artifacts_count": len(result.get("artifacts", [])),
"summary": f"User: {message[:150]} → Agent: {content[:150]}",
"review": "",
})
if len(session.task_history) > 20:
session.task_history = session.task_history[-20:]
session.task_history.append(
self._build_task_history_entry(
task_id=task.task_id,
message=message,
content=content,
agent_id=session.agent_id,
facts=task.facts_extracted,
key_data=key_data,
tool_executions=result.get("tool_executions", []),
artifacts_count=len(result.get("artifacts", [])),
)
)
session.task_history = self._trim_task_history(session.task_history)
# Clean old artifacts
artifacts = await self.memory.list_artifacts(session.session_id)
@@ -219,6 +224,52 @@ class OrchestratorEngine:
"status": "error",
}
@staticmethod
def _append_recent_messages(
existing: list[dict[str, Any]],
message: str,
conversation: list[dict[str, Any]],
) -> list[dict[str, Any]]:
merged = [OrchestratorEngine._sanitize_recent_message(m) for m in existing]
merged = [m for m in merged if m]
current_turn: list[dict[str, Any]] = []
if message.strip():
current_turn.append({"role": "user", "content": message})
for message_obj in conversation:
sanitized = OrchestratorEngine._sanitize_recent_message(message_obj)
if sanitized:
current_turn.append(sanitized)
merged.extend(current_turn)
return merged
@staticmethod
def _sanitize_recent_message(message: dict[str, Any]) -> dict[str, Any]:
role = str(message.get("role", "")).strip()
if role not in {"user", "assistant", "tool"}:
return {}
sanitized: dict[str, Any] = {"role": role}
content = message.get("content", "")
if isinstance(content, str) and content:
sanitized["content"] = content
if role == "assistant":
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
sanitized["tool_calls"] = tool_calls
if role == "tool":
tool_call_id = str(message.get("tool_call_id", "")).strip()
if tool_call_id:
sanitized["tool_call_id"] = tool_call_id
if "content" not in sanitized and "tool_calls" not in sanitized:
return {}
return sanitized
@staticmethod
def _extract_key_data_from_results(results: list[dict[str, Any]]) -> dict[str, Any]:
"""Extract structured data from tool executions for task history."""
@@ -252,3 +303,217 @@ class OrchestratorEngine:
if seen_modules:
key_data["modules"] = seen_modules[:20]
return key_data
@staticmethod
def _build_task_history_entry(
task_id: str,
message: str,
content: str,
agent_id: str,
facts: list[str],
key_data: dict[str, Any],
tool_executions: list[Any],
artifacts_count: int,
) -> dict[str, Any]:
message_summary = " ".join(message.strip().split())[:120]
content_summary = " ".join(content.strip().split())[:160]
if content_summary:
summary = f"User: {message_summary} → Agent: {content_summary}"
else:
summary = f"User: {message_summary}"
outcomes = OrchestratorEngine._extract_outcomes(content)
focus_refs = OrchestratorEngine._extract_focus_refs(
message=message,
content=content,
key_data=key_data,
outcomes=outcomes,
)
tools_used: list[str] = []
for tool_exec in tool_executions:
tool_name = getattr(tool_exec, "tool_name", "")
if tool_name and tool_name not in tools_used:
tools_used.append(tool_name)
return {
"task_id": task_id,
"objective": message[:200],
"agent_id": agent_id,
"status": "completed",
"steps": 1,
"facts": facts[-5:],
"key_data": key_data,
"tools_used": tools_used[:8],
"artifacts_count": artifacts_count,
"summary": summary,
"outcomes": outcomes,
"focus_refs": focus_refs,
"review": "",
}
@staticmethod
def _trim_task_history(history: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not history:
return []
trimmed = history[-settings.task_history_max_entries:]
kept: list[dict[str, Any]] = []
total_tokens = 0
for entry in reversed(trimmed):
entry_tokens = OrchestratorEngine._estimate_task_history_entry_tokens(entry)
if kept and total_tokens + entry_tokens > settings.task_history_max_tokens:
break
kept.append(entry)
total_tokens += entry_tokens
return list(reversed(kept))
@staticmethod
def _estimate_task_history_entry_tokens(entry: dict[str, Any]) -> int:
parts = [
entry.get("objective", ""),
entry.get("summary", ""),
" ".join(entry.get("facts", [])[:5]),
" ".join(entry.get("tools_used", [])[:5]),
str(entry.get("key_data", {})),
" ".join(entry.get("outcomes", [])[:3]),
str(entry.get("focus_refs", [])[:3]),
]
return estimate_tokens("\n".join(p for p in parts if p))
@staticmethod
def _extract_outcomes(content: str) -> list[str]:
if not content:
return []
normalized_lines = []
for raw_line in content.splitlines():
line = raw_line.strip()
if not line:
continue
line = re.sub(r"^[#>\-\*\d\.\)\s]+", "", line).strip()
if not line:
continue
normalized_lines.append(line)
keywords = (
"si tuviera que elegir",
"más flojo",
"mas flojo",
"más problem",
"mas problem",
"recomiendo",
"recomendación",
"recomendacion",
"prioridad",
"conclus",
"debería",
"deberia",
"peor",
"más débil",
"mas debil",
)
outcomes: list[str] = []
seen: set[str] = set()
for line in normalized_lines:
lower = line.lower()
if any(k in lower for k in keywords):
trimmed = line[:220]
if trimmed not in seen:
seen.add(trimmed)
outcomes.append(trimmed)
if len(outcomes) >= 3:
return outcomes
for line in normalized_lines:
if len(line) < 20:
continue
trimmed = line[:180]
if trimmed not in seen:
seen.add(trimmed)
outcomes.append(trimmed)
if len(outcomes) >= 2:
break
return outcomes[:3]
@staticmethod
def _extract_focus_refs(
message: str,
content: str,
key_data: dict[str, Any],
outcomes: list[str],
) -> list[dict[str, str]]:
refs: list[dict[str, str]] = []
seen: set[tuple[str, str, str]] = set()
def add_ref(ref_type: str, label: str, ref_id: str = "", role: str = "related") -> None:
label = label.strip()
ref_id = ref_id.strip()
if not label and not ref_id:
return
key = (ref_type, label, ref_id)
if key in seen:
return
seen.add(key)
refs.append({
"type": ref_type,
"label": label or ref_id,
"id": ref_id,
"role": role,
})
for table, nums in key_data.get("tables", {}).items():
add_ref("table", table, table, "related")
for num in nums[:3]:
add_ref("record", f"{table} record {num}", f"{table}:{num}", "related")
for section in key_data.get("sections", [])[:5]:
add_ref("section", section, section, "related")
for module in key_data.get("modules", [])[:5]:
add_ref("module", module, module, "related")
source_text = "\n".join(outcomes + [content[:1200]])
for line in outcomes:
for match in re.findall(r"\*\*([^*]{2,80})\*\*", line):
add_ref(
OrchestratorEngine._infer_ref_type(match, line, message),
match,
"",
"primary_focus",
)
if not any(ref["role"] == "primary_focus" for ref in refs):
for pattern in (
r"(?:elegir(?:\s+\*\*uno\*\*)?,?\s+dir[ií]a que\s+\*\*([^*]{2,80})\*\*)",
r"(?:el [^.\n]{0,40}m[aá]s flojo(?:[^.\n]{0,40})es\s+\*\*([^*]{2,80})\*\*)",
):
match = re.search(pattern, source_text, flags=re.IGNORECASE)
if match:
label = match.group(1).strip()
add_ref(
OrchestratorEngine._infer_ref_type(label, source_text, message),
label,
"",
"primary_focus",
)
break
return refs[:8]
@staticmethod
def _infer_ref_type(label: str, context: str, message: str) -> str:
text = f"{label} {context} {message}".lower()
if any(k in text for k in ("módulo", "modulo")):
return "module"
if any(k in text for k in ("página", "pagina", "apartado")):
return "page"
if "tabla" in text:
return "table"
if any(k in text for k in ("archivo", "file", ".tpl", ".php", ".js", ".css")):
return "file"
if any(k in text for k in ("sección", "seccion", "section")):
return "section"
return "entity"

View File

@@ -0,0 +1,472 @@
"""Tests para budget efectivo de contexto e integracion del ContextEngine."""
from __future__ import annotations
import asyncio
import enum
import sys
import types
import pytest
if not hasattr(enum, "StrEnum"):
class _CompatStrEnum(str, enum.Enum):
pass
enum.StrEnum = _CompatStrEnum
if "anthropic" not in sys.modules:
anthropic_stub = types.ModuleType("anthropic")
class _AsyncAnthropic:
pass
anthropic_stub.AsyncAnthropic = _AsyncAnthropic
sys.modules["anthropic"] = anthropic_stub
if "openai" not in sys.modules:
openai_stub = types.ModuleType("openai")
class _AsyncOpenAI:
pass
openai_stub.AsyncOpenAI = _AsyncOpenAI
sys.modules["openai"] = openai_stub
from src.config import Settings, settings
from src.context.compactor import ContextCompactor
from src.context.engine import ContextEngine
from src.models.agent import AgentProfile
from src.models.artifacts import ArtifactSummary
from src.models.session import SessionState
from src.orchestrator.engine import OrchestratorEngine
from src.orchestrator.agents.base import BaseAgent
class TestSettingsBudget:
def test_effective_budget_uses_explicit_override(self):
cfg = Settings(
context_max_tokens=120_000,
model_context_window=200_000,
model_max_output_tokens=8_192,
_env_file=None,
)
assert cfg.effective_context_budget == 120_000
def test_effective_budget_uses_model_window_when_no_override(self):
cfg = Settings(
context_max_tokens=0,
model_context_window=200_000,
model_max_output_tokens=8_000,
context_reserve_ratio=0.10,
_env_file=None,
)
assert cfg.reserve_tokens == 20_000
assert cfg.effective_context_budget == 172_000
assert cfg.effective_compaction_threshold == 137_600
class TestContextEngine:
def test_build_context_keeps_task_history_and_current_task(self):
session = SessionState(
immutable_rules=["No romper el proyecto"],
project_profile={"name": "demo"},
task_history=[
{
"task_id": "prev1",
"objective": "Crear banner",
"status": "completed",
"summary": "User: Crear banner → Agent: Banner creado",
"facts": ["Section: home"],
"key_data": {"sections": ["home"]},
"tools_used": ["create_module"],
}
],
)
session.begin_task("Actualizar hero")
agent = AgentProfile(
role="acai",
name="Acai",
system_prompt="Haz el trabajo.",
)
package = asyncio.run(ContextEngine().build_context(session=session, agent=agent))
assert "# Session History" in package.system_prompt
assert "# Current Task" in package.system_prompt
def test_build_context_includes_artifact_memory_with_task_state_agents(self):
session = SessionState(
immutable_rules=["No romper el proyecto"],
project_profile={"name": "demo"},
)
task = session.begin_task("Revisar modulo")
agent = AgentProfile(
role="acai",
name="Acai",
system_prompt="Haz el trabajo.",
context_sections=[
"immutable_rules",
"project_profile",
"task_state",
],
)
artifacts = [
ArtifactSummary(
artifact_id="art-1",
session_id=session.session_id,
task_id=task.task_id,
artifact_type="code",
title="Output of read_file",
summary="Resumen del archivo",
facts=["Status: ok"],
source_tool="read_file",
char_count=120,
)
]
package = asyncio.run(
ContextEngine().build_context(
session=session,
agent=agent,
artifacts=artifacts,
)
)
assert "## Artifacts" in package.system_prompt
assert "Resumen del archivo" in package.system_prompt
def test_build_messages_prefers_recent_raw_conversation_over_synthetic_history(self):
session = SessionState(
immutable_rules=["No romper el proyecto"],
task_history=[
{
"task_id": "prev1",
"objective": "Revísame la home y dime qué módulo ves más flojo",
"status": "completed",
"summary": "User: home → Agent: el módulo más flojo es Desplegables",
"facts": [],
"key_data": {"sections": ["u30mz"]},
"tools_used": ["get_module_config_vars"],
}
],
recent_messages=[
{"role": "user", "content": "Revísame la home y dime qué módulo ves más flojo"},
{"role": "assistant", "content": "El módulo más flojo es Desplegables."},
],
)
session.begin_task("Céntrate solo en ese módulo y dime qué cambiarías")
messages = ContextEngine()._build_messages(session)
assert messages[0]["content"] == "Revísame la home y dime qué módulo ves más flojo"
assert messages[1]["content"] == "El módulo más flojo es Desplegables."
assert "[HISTORIAL DE CONVERSACIÓN ANTERIOR" not in messages[0]["content"]
assert "Desplegables" in messages[-1]["content"]
def test_build_context_keeps_recent_raw_conversation_across_tasks(self):
session = SessionState(
immutable_rules=["No romper el proyecto"],
recent_messages=[
{"role": "user", "content": "Revísame la home y dime qué módulo ves más flojo"},
{"role": "assistant", "content": "El módulo más flojo es Desplegables."},
],
task_history=[
{
"task_id": "prev1",
"objective": "Revísame la home y dime qué módulo ves más flojo",
"status": "completed",
"summary": "User: home → Agent: el módulo más flojo es Desplegables",
"facts": [],
"key_data": {"sections": ["u30mz"]},
"tools_used": ["get_module_config_vars"],
"outcomes": ["El módulo más flojo es Desplegables."],
"focus_refs": [
{
"type": "module",
"label": "Desplegables",
"id": "u30mz",
"role": "primary_focus",
}
],
}
],
)
session.begin_task("Céntrate solo en ese módulo y dime qué cambiarías")
agent = AgentProfile(
role="acai",
name="Acai",
system_prompt="Haz el trabajo.",
context_sections=["immutable_rules", "task_state"],
)
package = asyncio.run(ContextEngine().build_context(session=session, agent=agent))
assert package.messages[0]["content"] == "Revísame la home y dime qué módulo ves más flojo"
assert package.messages[1]["content"] == "El módulo más flojo es Desplegables."
assert "Resolved Follow-up Context" in package.system_prompt
assert "Desplegables" in package.messages[-1]["content"]
def test_classify_followup_mode_detects_transform_requests(self):
mode = ContextEngine._classify_followup_mode(
"Hazme una segunda versión más comercial, pero sin cambiar el foco."
)
assert mode == "transform"
def test_classify_followup_mode_detects_fetch_requests(self):
mode = ContextEngine._classify_followup_mode(
"Céntrate en ese módulo y revisa la configuración actual."
)
assert mode == "fetch_more"
def test_build_context_sets_transform_followup_mode_in_task_state(self):
session = SessionState(
immutable_rules=["No romper el proyecto"],
recent_messages=[
{"role": "user", "content": "Dame una propuesta para ese módulo"},
{"role": "assistant", "content": "La propuesta actual es esta."},
],
)
session.begin_task("Hazme una segunda versión más comercial, pero sin cambiar el foco.")
agent = AgentProfile(
role="acai",
name="Acai",
system_prompt="Haz el trabajo.",
context_sections=["immutable_rules", "task_state"],
)
package = asyncio.run(ContextEngine().build_context(session=session, agent=agent))
assert "**Follow-up Mode**: transform" in package.system_prompt
assert "No llames herramientas salvo que falte un dato factual critico" in package.system_prompt
class TestTaskHistoryTrim:
def test_trim_respects_entry_limit_and_token_budget(self, monkeypatch):
monkeypatch.setattr(settings, "task_history_max_entries", 3)
monkeypatch.setattr(settings, "task_history_max_tokens", 60)
history = [
{"objective": "old", "summary": "muy antiguo", "facts": [], "tools_used": [], "key_data": {}},
{
"objective": "medio",
"summary": "contenido " * 20,
"facts": [],
"tools_used": [],
"key_data": {},
},
{"objective": "nuevo", "summary": "corto", "facts": [], "tools_used": [], "key_data": {}},
{"objective": "final", "summary": "ultimo", "facts": [], "tools_used": [], "key_data": {}},
]
trimmed = OrchestratorEngine._trim_task_history(history)
assert len(trimmed) <= 3
assert trimmed[-1]["objective"] == "final"
assert all(entry["objective"] != "old" for entry in trimmed)
def test_append_recent_messages_keeps_user_and_raw_turn_messages(self):
merged = OrchestratorEngine._append_recent_messages(
existing=[
{"role": "user", "content": "Pregunta anterior"},
{"role": "assistant", "content": "Respuesta anterior"},
],
message="Nueva pregunta",
conversation=[
{"role": "assistant", "content": "Voy a revisarlo."},
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado tool"},
{"role": "assistant", "content": "Respuesta final"},
],
)
assert [m["role"] for m in merged] == [
"user",
"assistant",
"user",
"assistant",
"tool",
"assistant",
]
assert merged[2]["content"] == "Nueva pregunta"
assert merged[4]["tool_call_id"] == "tool-1"
class TestConversationCompaction:
def test_compactor_preserves_last_user_and_compacts_old_tool_results(self):
compactor = ContextCompactor(max_tokens=999999)
messages = [
{"role": "user", "content": "Contexto anterior " * 10},
{"role": "assistant", "content": "Voy a revisar el modulo ahora mismo. " * 6},
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado antiguo\n" * 80},
{"role": "assistant", "content": "He visto el resultado anterior. " * 6},
{"role": "tool", "tool_call_id": "tool-2", "content": "resultado reciente\n" * 80},
{"role": "user", "content": "Este es el ultimo mensaje del usuario y debe quedar intacto."},
]
compacted, meta = compactor.compact_conversation(
messages,
max_tokens=420,
recent_raw_limit=1,
raw_char_limit=120,
)
assert compacted[-1]["content"] == messages[-1]["content"]
assert compacted[2]["content"].startswith("[TOOL RESULT COMPACTADO]")
assert compacted[4]["content"].startswith("resultado reciente")
assert compacted[1]["content"] == messages[1]["content"]
assert meta["messages_compacted"] > 0
assert meta["raw_tool_results_kept"] == 1
assert meta["tool_messages_compacted"] > 0
assert meta["assistant_messages_compacted"] == 0
assert meta["user_messages_compacted"] == 0
def test_engine_reports_conversation_compaction_when_budget_is_small(self, monkeypatch):
monkeypatch.setattr(settings, "context_max_tokens", 1400)
monkeypatch.setattr(settings, "compaction_threshold_tokens", 1)
monkeypatch.setattr(settings, "knowledge_base_max_tokens", 0)
monkeypatch.setattr(settings, "tool_raw_output_max_chars", 120)
monkeypatch.setattr(settings, "conversation_recent_raw_limit", 1)
session = SessionState(immutable_rules=["No romper el proyecto"])
session.begin_task("Revisar modulo")
agent = AgentProfile(
role="acai",
name="Acai",
system_prompt="Haz el trabajo.",
context_sections=["immutable_rules", "task_state"],
)
conversation = [
{"role": "assistant", "content": "Respuesta intermedia " * 25},
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado antiguo\n" * 80},
{"role": "assistant", "content": "Segunda respuesta " * 25},
{"role": "tool", "tool_call_id": "tool-2", "content": "resultado reciente\n" * 80},
]
engine = ContextEngine()
asyncio.run(
engine.build_context(
session=session,
agent=agent,
conversation=conversation,
)
)
debug = engine.get_last_context_debug(session.session_id)
assert debug is not None
assert debug["conversation_compaction"]["messages_compacted"] > 0
assert debug["message_tokens"] <= debug["message_tokens_before_compaction"]
def test_compactor_only_touches_user_messages_as_last_resort(self):
compactor = ContextCompactor(max_tokens=999999)
messages = [
{"role": "user", "content": "Contexto previo del usuario " * 8},
{"role": "assistant", "content": "Respuesta previa del asistente " * 6},
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado viejo\n" * 80},
{"role": "user", "content": "Ultimo mensaje del usuario"},
]
compacted, meta = compactor.compact_conversation(
messages,
max_tokens=420,
recent_raw_limit=0,
raw_char_limit=120,
)
assert compacted[0]["content"] == messages[0]["content"]
assert compacted[1]["content"] == messages[1]["content"]
assert compacted[2]["content"].startswith("[TOOL RESULT COMPACTADO]")
assert compacted[3]["content"] == messages[3]["content"]
assert meta["tool_messages_compacted"] > 0
assert meta["assistant_messages_compacted"] == 0
assert meta["user_messages_compacted"] == 0
class TestStructuredFollowups:
def test_history_entry_extracts_outcomes_and_focus_refs(self):
entry = OrchestratorEngine._build_task_history_entry(
task_id="task-1",
message="Revísame la home y dime qué módulo ves más flojo",
content=(
"## El módulo más flojo\n"
"Si tuviera que elegir uno, diría que **Desplegables** es el más problemático.\n"
"Recomiendo revisarlo primero."
),
agent_id="acai",
facts=[],
key_data={"sections": ["u30mz"]},
tool_executions=[],
artifacts_count=0,
)
assert any("Desplegables" in outcome for outcome in entry["outcomes"])
assert any(ref["label"] == "Desplegables" for ref in entry["focus_refs"])
def test_followup_message_includes_resolved_context(self):
session = SessionState(
immutable_rules=["No romper el proyecto"],
task_history=[
{
"task_id": "prev1",
"objective": "Revísame la home y dime qué módulo ves más flojo",
"status": "completed",
"summary": "User: home → Agent: el módulo más flojo es Desplegables",
"facts": [],
"key_data": {"sections": ["u30mz"]},
"tools_used": ["get_module_config_vars"],
"outcomes": ["Si tuviera que elegir uno, diría que Desplegables es el más problemático."],
"focus_refs": [
{
"type": "module",
"label": "Desplegables",
"id": "u30mz",
"role": "primary_focus",
}
],
}
],
)
session.begin_task("Céntrate solo en ese módulo y dime qué cambiarías")
engine = ContextEngine()
messages = engine._build_messages(session)
assert "[CONTEXTO RESUELTO DEL TURNO ANTERIOR]" in messages[-1]["content"]
assert "Desplegables" in messages[-1]["content"]
class _DummyMCP:
is_running = True
def get_tool_definitions(self):
return [
{"name": "tool_a"},
{"name": "tool_b"},
]
class TestToolGating:
def test_base_agent_disables_tools_for_transform_followups(self):
agent = BaseAgent(
profile=AgentProfile(role="acai", name="Acai", allowed_tools=["tool_a", "tool_b"]),
model_adapter=None,
context_engine=None,
mcp_client=_DummyMCP(),
memory_store=None,
sse_emitter=None,
)
assert agent._get_allowed_tools(followup_mode="transform") == []
def test_base_agent_keeps_tools_for_non_transform_followups(self):
agent = BaseAgent(
profile=AgentProfile(role="acai", name="Acai", allowed_tools=["tool_a"]),
model_adapter=None,
context_engine=None,
mcp_client=_DummyMCP(),
memory_store=None,
sse_emitter=None,
)
tools = agent._get_allowed_tools(followup_mode="fetch_more")
assert tools == [{"name": "tool_a"}]