Compare commits
2 Commits
993e7d3000
...
237dc00379
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
237dc00379 | ||
|
|
4c73d848bb |
@@ -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"><span> </span></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"><span> </span></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 <span>.</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>
|
||||
```
|
||||
@@ -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).
|
||||
@@ -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.",
|
||||
|
||||
2
mcp.json
2
mcp.json
@@ -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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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:]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
472
tests/test_context_budget.py
Normal file
472
tests/test_context_budget.py
Normal 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"}]
|
||||
Reference in New Issue
Block a user