Initial commit

This commit is contained in:
Jordan
2026-04-01 23:16:45 +01:00
commit 91cfdaee72
200 changed files with 25589 additions and 0 deletions

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
# Required (at least one)
ANTHROPIC_API_KEY=sk-ant-...
# AGENTIC_OPENAI_API_KEY=sk-...
# Provider selection: "claude" or "openai"
# AGENTIC_DEFAULT_MODEL_PROVIDER=claude
# Optional overrides
# AGENTIC_REDIS_HOST=localhost
# AGENTIC_REDIS_PORT=6379
# AGENTIC_REDIS_DB=0
# AGENTIC_REDIS_PASSWORD=
# AGENTIC_DEFAULT_MODEL_ID=claude-sonnet-4-20250514
# AGENTIC_DEFAULT_MODEL_ID=gpt-4o # if using openai
# AGENTIC_MAX_TOKENS=4096
# AGENTIC_DEBUG=false
# --- Acai MCP Server (stdio) ---
AGENTIC_MCP_SERVER_COMMAND=node
AGENTIC_MCP_SERVER_ARGS=["mcp-server/stdio.js"]
# Acai MCP environment variables (required for the MCP server)
ACAI_WEB_URL=http://localhost:8080
ACAI_WEBSITE=my-site
ACAI_PROJECT_DIR=/path/to/your/acai-project
# ACAI_TOKEN= (auto-read from .acai file)

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.12-slim AS base
WORKDIR /app
# Install system deps
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Install Python deps
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy source
COPY src/ ./src/
# Non-root user
RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

335
README.md Normal file
View File

@@ -0,0 +1,335 @@
# Agentic Microservice
Microservicio de orquestación agéntica con context engineering avanzado, multi-agent orchestration, integración MCP, SSE streaming y knowledge base. Diseñado como backend para Acai Code.
## Arquitectura
```
Client → API (FastAPI) → Orchestrator → Subagents (planner/coder/collector/reviewer)
↓ ↓
Context Engine Model Adapter (Claude / OpenAI)
↓ ↓
Memory Store + KB MCP Client (stdio)
Redis (sessions, events, knowledge)
```
**Principios:**
- **Context over chat history** — el modelo recibe estado estructurado, nunca raw tool outputs
- **Knowledge filtering** — solo los docs relevantes al task entran en contexto (keyword scoring)
- **Compaction** — extracción de facts, eliminación de redundancia, prioridades por sección
- **Artifact summarization** — outputs de tools resumidos antes de entrar al contexto
- **Error recovery** — steps fallidos se saltan, timeout global, sesión nunca se queda en executing
- **Concurrency lock** — Redis SETNX impide ejecución simultánea en la misma sesión
## Quick Start
### Con Docker Compose
```bash
# 1. Configurar
cp .env.example .env
# Editar .env — al menos una API key (ANTHROPIC o OPENAI)
# 2. Levantar todo (Redis + microservicio)
docker compose up --build
# 3. Solo Redis (para desarrollo local)
docker compose up redis -d
```
### Desarrollo local
```bash
# 1. Redis
docker compose up redis -d
# 2. Dependencias
pip install -r requirements.txt
# 3. Configurar .env
AGENTIC_OPENAI_API_KEY=sk-...
AGENTIC_DEFAULT_MODEL_PROVIDER=openai
AGENTIC_DEFAULT_MODEL_ID=gpt-4o
AGENTIC_REDIS_PORT=6380
# 4. Arrancar
python3 -m uvicorn src.main:app --reload --port 8001
# 5. Dashboard en http://localhost:8001/dashboard/
```
### Cargar Knowledge Base
```bash
# Cargar docs desde el directorio docs/
curl -X POST http://localhost:8001/api/v1/knowledge/load \
-H "Content-Type: application/json" \
-d '{"docs_path": "docs"}'
```
## Dashboard
Interfaz web para testing integrada en el microservicio. Se accede en `/dashboard/`.
**Features:**
- Gestión de sesiones (crear, eliminar, seleccionar)
- Chat con envío sync (Send) o streaming (Stream)
- Event Log en tiempo real con filtros por categoría y export JSON
- Inspector de estado (task, plan, facts, constraints, timeline)
- Context Debug — muestra exactamente qué recibe cada agente (secciones, tokens, previews)
- Dark/light mode
- Responsive (3 columnas → 1 columna en móvil)
**Atajos de teclado:**
- `Enter` → Send
- `Ctrl+Enter` → Stream
- `Shift+Enter` → Nueva línea
## API Reference
Base URL: `/api/v1`
### Sesiones
```bash
# Crear sesión
curl -X POST http://localhost:8001/api/v1/sessions \
-H "Content-Type: application/json" \
-d '{
"project_profile": {"name": "mi-proyecto", "tech_stack": ["python", "twig"]},
"immutable_rules": ["Responde siempre en español", "Usa Tailwind CSS"],
"metadata": {}
}'
# → {"session_id": "abc123...", "status": "idle"}
# Obtener estado
curl http://localhost:8001/api/v1/sessions/{session_id}
# Eliminar
curl -X DELETE http://localhost:8001/api/v1/sessions/{session_id}
```
### Mensajes
```bash
# Sync — espera la respuesta completa
curl -X POST http://localhost:8001/api/v1/sessions/{session_id}/messages \
-H "Content-Type: application/json" \
-d '{"message": "Crea un módulo FAQ con acordeón"}'
# Streaming — retorna inmediatamente, resultados vía SSE
curl -X POST http://localhost:8001/api/v1/sessions/{session_id}/messages \
-H "Content-Type: application/json" \
-d '{"message": "Crea un módulo FAQ con acordeón", "stream": true}'
```
Respuesta sync:
```json
{
"session_id": "...",
"task_id": "...",
"content": "### Step 1\n...\n### Review\n...",
"steps_completed": 5,
"steps_failed": [],
"artifacts_count": 0,
"review": "...",
"status": "completed"
}
```
### SSE Streaming
```bash
curl -N http://localhost:8001/api/v1/sessions/{session_id}/stream
```
Tipos de evento:
| Evento | Cuándo |
|--------|--------|
| `session.created` | Sesión inicializada |
| `execution.started` | Pipeline arranca |
| `subagent.assigned` | Step ruteado a un agente |
| `agent.delta` | Chunk de texto del agente |
| `tool.started` | Herramienta MCP ejecutándose |
| `tool.completed` | Herramienta terminó |
| `execution.completed` | Task completado |
| `error` | Error en step, planning, o timeout |
### Knowledge Base
```bash
# Cargar docs desde directorio
curl -X POST http://localhost:8001/api/v1/knowledge/load \
-H "Content-Type: application/json" \
-d '{"docs_path": "docs"}'
# Cargar desde ruta absoluta
curl -X POST http://localhost:8001/api/v1/knowledge/load \
-H "Content-Type: application/json" \
-d '{"docs_path": "/ruta/a/mis/docs"}'
# Listar docs cargados
curl http://localhost:8001/api/v1/knowledge
# Eliminar un doc
curl -X DELETE http://localhost:8001/api/v1/knowledge/{doc_id}
```
Los docs se cargan como `*.md` del directorio. Se sobreescriben si ya existen. No requiere reinicio — la siguiente conversación usa los docs actualizados.
### Debug
```bash
# Historial de eventos (persistidos en Redis)
curl http://localhost:8001/api/v1/sessions/{session_id}/events
# Context debug — qué recibió cada agente
curl http://localhost:8001/api/v1/sessions/{session_id}/context-debug
# Health check
curl http://localhost:8001/health
```
## Context Engine
El componente más crítico del sistema. Ensambla el prompt que recibe el modelo con secciones priorizadas:
| Sección | Prioridad | Contenido |
|---------|-----------|-----------|
| `immutable_rules` | 100 | System prompt del agente + reglas de sesión. Nunca se recorta |
| `project_profile` | 80 | Perfil del proyecto (nombre, tech stack) |
| `knowledge_base` | 60 | Docs relevantes filtrados por keywords del task |
| `task_state` | 70 | Objetivo, plan, step actual, facts, constraints |
| `artifact_memory` | 50 | Resúmenes de outputs de herramientas |
| `working_context` | 30 | Items de trabajo recientes entre steps |
**Knowledge filtering:** no carga todos los docs. Extrae keywords del objetivo y step actual, puntúa cada doc (título ×10, tags ×5, contenido ×1), y selecciona los más relevantes dentro de un budget de 15k tokens. Los docs que no caben entran como summary de una línea.
**Token counting:** usa `tiktoken` (encoding `cl100k_base`) para conteo real. Fallback a estimación si tiktoken no está instalado.
**Compaction:** si el total excede el context window (120k tokens default), las secciones de menor prioridad se comprimen o eliminan. `immutable_rules` nunca se toca.
## Orchestrator Pipeline
```
mensaje → planner → [step₁ → step₂ → ... → stepₙ] → reviewer → respuesta
```
1. **Planner** descompone el mensaje en pasos con agent role asignado
2. **Router** rutea cada step al agente apropiado (keyword matching: implement→coder, buscar→collector, etc.)
3. **Subagent** ejecuta el step con su propio contexto controlado
4. Si un step **falla**, se marca como failed y el pipeline continúa con el siguiente
5. **Reviewer** valida el trabajo si hubo >1 step
6. **Timeout global** de 5 min (configurable) — si se excede, la sesión pasa a error
**Concurrency:** Redis lock (SETNX) con TTL de 5 min. Si llega un segundo mensaje mientras uno se ejecuta → `{"status": "busy"}`. El lock se libera automáticamente si el proceso muere.
## MCP (Model Context Protocol)
Cliente stdio que se conecta a un servidor MCP al arrancar:
```bash
# En .env
AGENTIC_MCP_SERVER_COMMAND=node
AGENTIC_MCP_SERVER_ARGS=["mcp-server/stdio.js"]
# Variables del MCP server (se heredan al subproceso)
ACAI_WEB_URL=http://localhost:8080
ACAI_WEBSITE=mi-sitio
ACAI_PROJECT_DIR=/ruta/al/proyecto
```
El cliente descubre tools automáticamente vía `tools/list`, y los agentes las usan durante la ejecución. Los resultados de tools **nunca** entran al contexto como raw output — se resumen como artifacts.
## Configuración
Variables de entorno con prefijo `AGENTIC_`:
| Variable | Default | Descripción |
|----------|---------|-------------|
| `AGENTIC_ANTHROPIC_API_KEY` | — | API key de Anthropic |
| `AGENTIC_OPENAI_API_KEY` | — | API key de OpenAI |
| `AGENTIC_DEFAULT_MODEL_PROVIDER` | `claude` | `claude` o `openai` |
| `AGENTIC_DEFAULT_MODEL_ID` | `claude-sonnet-4-20250514` | Modelo por defecto |
| `AGENTIC_REDIS_HOST` | `localhost` | Host de Redis |
| `AGENTIC_REDIS_PORT` | `6379` | Puerto de Redis |
| `AGENTIC_REDIS_DB` | `0` | Base de datos Redis |
| `AGENTIC_REDIS_PASSWORD` | — | Password de Redis |
| `AGENTIC_MAX_TOKENS` | `4096` | Max tokens de salida por llamada |
| `AGENTIC_CONTEXT_MAX_TOKENS` | `120000` | Max context window |
| `AGENTIC_MAX_EXECUTION_STEPS` | `25` | Max steps por task |
| `AGENTIC_MAX_EXECUTION_TIMEOUT_SECONDS` | `300` | Timeout global (5 min) |
| `AGENTIC_SUBAGENT_MAX_STEPS` | `10` | Max iterations por subagent |
| `AGENTIC_MCP_SERVER_COMMAND` | — | Comando del servidor MCP |
| `AGENTIC_MCP_SERVER_ARGS` | `[]` | Argumentos del servidor MCP |
| `AGENTIC_MCP_TIMEOUT_SECONDS` | `30` | Timeout por tool call |
| `AGENTIC_DEBUG` | `false` | Logging verbose |
## Redis Key Structure
```
agentic:session:{id} → SessionState JSON (TTL 24h)
agentic:session:{id}:artifacts → Hash de ArtifactSummary
agentic:session:{id}:events → Lista de SSE events (cap 500)
agentic:session:{id}:lock → Execution lock (SETNX, TTL 5min)
agentic:sessions:index → Set de session IDs activos
agentic:memory:knowledge:{doc_id} → MemoryDocument JSON
agentic:memory:knowledge:_index → Set de doc IDs
agentic:memory:_tag:{tag} → Set de doc IDs por tag
agentic:memory:_type:{type} → Set de doc IDs por tipo
```
## Project Structure
```
.
├── src/
│ ├── main.py # FastAPI app, lifecycle, wiring
│ ├── config.py # Pydantic settings
│ ├── models/ # Pydantic v2 data models
│ │ ├── session.py # SessionState, TaskState, TaskStep
│ │ ├── context.py # ContextPackage, MemoryDocument
│ │ ├── agent.py # AgentProfile, SubAgentDefinition
│ │ ├── artifacts.py # ArtifactSummary
│ │ └── tools.py # ToolExecution, ToolDefinition
│ ├── context/ # Context Engine (core)
│ │ ├── engine.py # Prompt assembly, knowledge filtering, debug
│ │ └── compactor.py # Compaction, summarization, tiktoken
│ ├── memory/ # Persistent memory
│ │ └── store.py # Redis-backed memory + embeddings
│ ├── adapters/ # Model provider adapters
│ │ ├── base.py # ModelAdapter interface
│ │ ├── claude_adapter.py # Anthropic Claude (streaming)
│ │ └── openai_adapter.py # OpenAI GPT (streaming)
│ ├── mcp/ # MCP client
│ │ └── client.py # stdio transport, tool registry
│ ├── orchestrator/ # Agent orchestration
│ │ ├── engine.py # Pipeline + error recovery + timeout
│ │ ├── router.py # Step-to-agent routing
│ │ └── agents/
│ │ ├── base.py # Shared execution loop
│ │ ├── planner.py # Plan decomposition
│ │ ├── coder.py # Implementation
│ │ ├── collector.py # Context gathering
│ │ └── reviewer.py # Validation
│ ├── streaming/ # SSE streaming
│ │ └── sse.py # Event emitter + Redis persistence
│ ├── storage/ # Persistence
│ │ └── redis.py # Sessions, events, locks
│ └── api/ # REST endpoints
│ └── routes.py # Sessions, messages, knowledge, debug
├── dashboard/ # Testing UI (vanilla JS, zero build)
│ ├── index.html
│ ├── css/main.css
│ └── js/
│ ├── app.js # State, SSE handlers
│ ├── api.js # Fetch + EventSource
│ └── components/ # Sidebar, chat, event log, inspector, timeline
├── mcp-server/ # Acai MCP server (Node.js, stdio)
├── docs/ # Knowledge base documents (*.md)
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── .env.example
```

884
dashboard/css/main.css Normal file
View File

@@ -0,0 +1,884 @@
/* ============================================================
Agentic Dashboard — Design System
============================================================ */
/* --- CSS Custom Properties --- */
:root {
/* Backgrounds */
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-surface: #1c2128;
--bg-hover: #252c35;
--bg-input: #0d1117;
/* Text */
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--text-inverse: #0d1117;
/* Accent */
--accent: #58a6ff;
--accent-hover: #79c0ff;
--accent-muted: #1f3a5f;
/* Status */
--status-success: #3fb950;
--status-warning: #d29922;
--status-error: #f85149;
--status-info: #58a6ff;
/* Agent roles */
--agent-planner: #bc8cff;
--agent-coder: #3fb950;
--agent-collector: #d29922;
--agent-reviewer: #f778ba;
--agent-orchestrator: #58a6ff;
/* Event types */
--event-lifecycle: #58a6ff;
--event-content: #3fb950;
--event-tool: #d29922;
--event-orchestration: #bc8cff;
--event-error: #f85149;
--event-keepalive: #6e7681;
/* Borders */
--border: #30363d;
--border-subtle: #21262d;
/* Misc */
--radius: 8px;
--radius-sm: 4px;
--radius-lg: 12px;
--shadow: 0 2px 8px rgba(0,0,0,.3);
--transition: 150ms ease;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
/* Layout */
--sidebar-width: 260px;
--inspector-width: 340px;
--toolbar-height: 48px;
}
/* --- Light theme --- */
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-surface: #ffffff;
--bg-hover: #f3f4f6;
--bg-input: #f6f8fa;
--text-primary: #1f2328;
--text-secondary: #656d76;
--text-muted: #8b949e;
--text-inverse: #ffffff;
--accent: #0969da;
--accent-hover: #0550ae;
--accent-muted: #ddf4ff;
--border: #d0d7de;
--border-subtle: #e1e4e8;
--shadow: 0 1px 3px rgba(0,0,0,.12);
--bg-surface: #f6f8fa;
}
/* --- Reset & Base --- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
background: var(--bg-primary);
overflow: hidden;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
/* --- Scrollbar --- */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ============================================================
Layout — 3-column grid
============================================================ */
#app {
display: grid;
grid-template-rows: var(--toolbar-height) 1fr;
grid-template-columns: var(--sidebar-width) 1fr var(--inspector-width);
grid-template-areas:
"toolbar toolbar toolbar"
"sidebar main inspector";
height: 100vh;
}
#toolbar { grid-area: toolbar; }
#sidebar { grid-area: sidebar; }
#main { grid-area: main; }
#inspector { grid-area: inspector; }
/* ============================================================
Toolbar
============================================================ */
#toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
z-index: 10;
}
.toolbar-brand {
font-weight: 600;
font-size: 15px;
color: var(--text-primary);
white-space: nowrap;
}
.toolbar-separator {
width: 1px;
height: 20px;
background: var(--border);
}
.toolbar-session-id {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
padding: 2px 8px;
border-radius: var(--radius-sm);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-session-id:hover { background: var(--bg-hover); }
.toolbar-spacer { flex: 1; }
.toolbar-actions {
display: flex;
gap: 6px;
align-items: center;
}
/* ============================================================
Sidebar
============================================================ */
#sidebar {
display: flex;
flex-direction: column;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border-subtle);
}
.sidebar-header h3 {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .5px;
color: var(--text-secondary);
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.session-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--radius);
cursor: pointer;
transition: background var(--transition);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
}
.session-item:hover { background: var(--bg-hover); }
.session-item.active {
background: var(--accent-muted);
color: var(--accent);
}
.session-item .delete-btn {
margin-left: auto;
opacity: 0;
transition: opacity var(--transition);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 2px 4px;
border-radius: var(--radius-sm);
}
.session-item:hover .delete-btn { opacity: 1; }
.session-item .delete-btn:hover { color: var(--status-error); }
.sidebar-footer {
padding: 10px 14px;
border-top: 1px solid var(--border-subtle);
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
}
/* ============================================================
Status Dot
============================================================ */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.idle { background: var(--status-success); }
.status-dot.active { background: var(--status-success); }
.status-dot.executing { background: var(--status-warning); animation: pulse 1.5s infinite; }
.status-dot.completed { background: var(--text-muted); }
.status-dot.error { background: var(--status-error); }
.status-dot.connected { background: var(--status-success); }
.status-dot.disconnected { background: var(--status-error); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: .4; }
}
/* ============================================================
Main Panel
============================================================ */
#main {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-primary);
}
/* --- Chat messages --- */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.chat-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-muted);
gap: 8px;
}
.chat-empty .icon { font-size: 40px; opacity: .3; }
.chat-empty p { font-size: 13px; }
.message {
max-width: 85%;
padding: 12px 16px;
border-radius: var(--radius-lg);
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
}
.message.user {
align-self: flex-end;
background: var(--accent);
color: var(--text-inverse);
border-bottom-right-radius: 4px;
}
.message.assistant {
align-self: flex-start;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-bottom-left-radius: 4px;
}
.message.assistant .agent-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .5px;
padding: 1px 6px;
border-radius: 3px;
margin-bottom: 6px;
}
.message.system {
align-self: center;
background: transparent;
color: var(--text-muted);
font-size: 12px;
padding: 4px 12px;
border: 1px dashed var(--border);
border-radius: var(--radius);
}
/* Markdown inside messages */
.message h1, .message h2, .message h3 {
margin: 8px 0 4px;
font-size: 14px;
font-weight: 700;
}
.message h3 { font-size: 13px; }
.message p { margin: 4px 0; }
.message ul, .message ol { padding-left: 20px; margin: 4px 0; }
.message pre {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin: 8px 0;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
}
.message code {
font-family: var(--font-mono);
font-size: 12px;
background: var(--bg-primary);
padding: 1px 5px;
border-radius: 3px;
}
.message pre code { background: none; padding: 0; }
.message strong { font-weight: 700; }
/* --- Execution indicator --- */
.execution-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
font-size: 12px;
color: var(--text-secondary);
align-self: flex-start;
}
.execution-indicator .spinner {
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* --- Chat input --- */
.chat-input-area {
padding: 12px 24px 16px;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
}
.chat-input-wrapper {
display: flex;
gap: 8px;
align-items: flex-end;
}
.chat-input-wrapper textarea {
flex: 1;
resize: none;
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text-primary);
border-radius: var(--radius);
padding: 10px 14px;
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
min-height: 42px;
max-height: 160px;
outline: none;
transition: border-color var(--transition);
}
.chat-input-wrapper textarea:focus { border-color: var(--accent); }
.chat-input-wrapper textarea::placeholder { color: var(--text-muted); }
.chat-input-wrapper textarea:disabled {
opacity: .5;
cursor: not-allowed;
}
/* ============================================================
Event Log (collapsible, below chat)
============================================================ */
.event-log-panel {
border-top: 1px solid var(--border);
background: var(--bg-secondary);
display: flex;
flex-direction: column;
max-height: 0;
overflow: hidden;
transition: max-height .3s ease;
}
.event-log-panel.open { max-height: 280px; }
.event-log-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
cursor: pointer;
border-bottom: 1px solid var(--border-subtle);
user-select: none;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: .5px;
}
.event-log-header .chevron {
transition: transform var(--transition);
font-size: 10px;
}
.event-log-panel.open .event-log-header .chevron { transform: rotate(180deg); }
.event-log-filters {
display: flex;
gap: 4px;
padding: 6px 12px;
flex-wrap: wrap;
border-bottom: 1px solid var(--border-subtle);
}
.event-filter-btn {
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition);
}
.event-filter-btn.active {
background: var(--accent-muted);
color: var(--accent);
border-color: var(--accent);
}
.event-log-entries {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
font-family: var(--font-mono);
font-size: 11px;
}
.event-entry {
display: flex;
gap: 8px;
padding: 3px 6px;
border-radius: var(--radius-sm);
align-items: flex-start;
}
.event-entry:hover { background: var(--bg-hover); }
.event-time { color: var(--text-muted); white-space: nowrap; flex-shrink: 0; }
.event-type-badge {
font-size: 10px;
padding: 0 6px;
border-radius: 3px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.event-data {
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
cursor: pointer;
}
.event-data.expanded {
white-space: pre-wrap;
word-break: break-all;
}
/* Event type colors */
.event-type-badge.lifecycle { background: rgba(88,166,255,.15); color: var(--event-lifecycle); }
.event-type-badge.content { background: rgba(63,185,80,.15); color: var(--event-content); }
.event-type-badge.tool { background: rgba(210,153,34,.15); color: var(--event-tool); }
.event-type-badge.orchestration { background: rgba(188,140,255,.15); color: var(--event-orchestration); }
.event-type-badge.error { background: rgba(248,81,73,.15); color: var(--event-error); }
.event-type-badge.keepalive { background: rgba(110,118,129,.1); color: var(--event-keepalive); }
/* ============================================================
Inspector (right panel)
============================================================ */
#inspector {
display: flex;
flex-direction: column;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
overflow-y: auto;
}
.inspector-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
}
.inspector-section {
padding: 12px 14px;
border-bottom: 1px solid var(--border-subtle);
}
.inspector-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .5px;
color: var(--text-muted);
margin-bottom: 8px;
}
.inspector-field {
display: flex;
justify-content: space-between;
align-items: center;
padding: 3px 0;
font-size: 13px;
}
.inspector-field .label {
color: var(--text-secondary);
}
.inspector-field .value {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
/* --- Status badge --- */
.badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 10px;
text-transform: capitalize;
}
.badge.idle { background: rgba(63,185,80,.15); color: var(--status-success); }
.badge.active { background: rgba(63,185,80,.15); color: var(--status-success); }
.badge.executing { background: rgba(210,153,34,.15); color: var(--status-warning); }
.badge.completed { background: rgba(110,118,129,.15); color: var(--text-muted); }
.badge.error { background: rgba(248,81,73,.15); color: var(--status-error); }
.badge.pending { background: rgba(110,118,129,.1); color: var(--text-muted); }
.badge.planning { background: rgba(188,140,255,.15); color: var(--agent-planner); }
.badge.reviewing { background: rgba(247,120,186,.15); color: var(--agent-reviewer); }
.badge.failed { background: rgba(248,81,73,.15); color: var(--status-error); }
/* --- Agent timeline --- */
.timeline {
position: relative;
padding-left: 20px;
}
.timeline::before {
content: '';
position: absolute;
left: 7px;
top: 0;
bottom: 0;
width: 2px;
background: var(--border);
}
.timeline-step {
position: relative;
padding: 6px 0 12px;
}
.timeline-step::before {
content: '';
position: absolute;
left: -17px;
top: 10px;
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid var(--border);
background: var(--bg-secondary);
}
.timeline-step.active::before {
border-color: var(--status-warning);
background: var(--status-warning);
animation: pulse 1.5s infinite;
}
.timeline-step.completed::before {
border-color: var(--status-success);
background: var(--status-success);
}
.timeline-step.failed::before {
border-color: var(--status-error);
background: var(--status-error);
}
.timeline-step-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.timeline-step-desc {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
line-height: 1.4;
}
.timeline-tools {
margin-top: 4px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tool-chip {
font-size: 10px;
font-family: var(--font-mono);
padding: 1px 6px;
border-radius: 3px;
background: rgba(210,153,34,.1);
color: var(--event-tool);
border: 1px solid rgba(210,153,34,.2);
}
/* --- Agent role badge --- */
.role-badge {
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: .3px;
}
.role-badge.planner { background: rgba(188,140,255,.15); color: var(--agent-planner); }
.role-badge.coder { background: rgba(63,185,80,.15); color: var(--agent-coder); }
.role-badge.collector { background: rgba(210,153,34,.15); color: var(--agent-collector); }
.role-badge.reviewer { background: rgba(247,120,186,.15); color: var(--agent-reviewer); }
.role-badge.orchestrator { background: rgba(88,166,255,.15); color: var(--agent-orchestrator); }
/* ============================================================
Buttons
============================================================ */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-primary);
font-size: 13px;
font-family: var(--font-sans);
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.btn:hover { background: var(--bg-hover); }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
color: var(--text-inverse);
border-color: var(--accent);
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-danger {
color: var(--status-error);
border-color: transparent;
background: transparent;
}
.btn-danger:hover { background: rgba(248,81,73,.1); }
.btn-sm { padding: 3px 10px; font-size: 12px; }
.btn-icon {
padding: 4px 8px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--radius-sm);
font-size: 16px;
}
.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); }
/* ============================================================
Modal
============================================================ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity .2s ease;
}
.modal-overlay.open { opacity: 1; pointer-events: auto; }
.modal {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 520px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 30px rgba(0,0,0,.4);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border-subtle);
}
.modal-header h2 { font-size: 15px; font-weight: 600; }
.modal-body { padding: 18px; }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px;
border-top: 1px solid var(--border-subtle);
}
/* --- Form fields --- */
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 4px;
}
.form-group textarea,
.form-group input {
width: 100%;
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text-primary);
border-radius: var(--radius);
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 12px;
outline: none;
transition: border-color var(--transition);
}
.form-group textarea:focus,
.form-group input:focus { border-color: var(--accent); }
.form-group textarea { min-height: 80px; resize: vertical; }
.form-group .error-text {
font-size: 11px;
color: var(--status-error);
margin-top: 3px;
}
.rules-list { display: flex; flex-direction: column; gap: 6px; }
.rule-row {
display: flex;
gap: 6px;
align-items: center;
}
.rule-row input { flex: 1; }
/* ============================================================
JSON tree viewer
============================================================ */
.json-tree details { margin-left: 14px; }
.json-tree summary {
cursor: pointer;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
user-select: none;
}
.json-tree summary:hover { color: var(--text-primary); }
.json-tree .json-key { color: var(--accent); }
.json-tree .json-string { color: var(--status-success); }
.json-tree .json-number { color: var(--agent-planner); }
.json-tree .json-bool { color: var(--status-warning); }
.json-tree .json-null { color: var(--text-muted); }
.json-tree .json-leaf {
margin-left: 14px;
font-family: var(--font-mono);
font-size: 12px;
padding: 1px 0;
}
/* ============================================================
Responsive
============================================================ */
@media (max-width: 1200px) {
#app {
grid-template-columns: var(--sidebar-width) 1fr;
grid-template-areas:
"toolbar toolbar"
"sidebar main";
}
#inspector { display: none; }
}
@media (max-width: 768px) {
#app {
grid-template-columns: 1fr;
grid-template-areas:
"toolbar"
"main";
}
#sidebar { display: none; }
}
/* ============================================================
Utility
============================================================ */
.hidden { display: none !important; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mono { font-family: var(--font-mono); }
.text-muted { color: var(--text-muted); }
.text-sm { font-size: 12px; }
.mt-2 { margin-top: 8px; }
.gap-4 { gap: 4px; }

33
dashboard/index.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agentic Dashboard</title>
<link rel="stylesheet" href="/dashboard/css/main.css">
</head>
<body>
<div id="app">
<div id="toolbar"></div>
<div id="sidebar"></div>
<div id="main">
<div class="chat-messages">
<div class="chat-empty">
<div class="icon">&#9881;</div>
<p>Loading...</p>
</div>
</div>
</div>
<div id="inspector">
<div class="inspector-empty">No session selected</div>
</div>
</div>
<!-- Modal overlay -->
<div class="modal-overlay" id="modal-overlay"></div>
<script type="module" src="/dashboard/js/app.js"></script>
</body>
</html>

133
dashboard/js/api.js Normal file
View File

@@ -0,0 +1,133 @@
/**
* API Client — all fetch calls + EventSource wrapper
*/
const BASE = '/api/v1';
class ApiClient {
constructor() {
this._eventSources = new Map();
this._bus = new EventTarget();
}
// --- Event bus ---
on(event, fn) { this._bus.addEventListener(event, fn); }
off(event, fn) { this._bus.removeEventListener(event, fn); }
_emit(event, detail) {
this._bus.dispatchEvent(new CustomEvent(event, { detail }));
}
// --- HTTP helpers ---
async _fetch(path, opts = {}) {
try {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
} catch (e) {
this._emit('error', { message: e.message, path });
throw e;
}
}
// --- Health ---
async checkHealth() {
try {
const data = await fetch('/health').then(r => r.json());
this._emit('health', data);
return data;
} catch {
this._emit('health', { status: 'disconnected' });
return { status: 'disconnected' };
}
}
// --- Sessions ---
async createSession(body) {
const data = await this._fetch('/sessions', {
method: 'POST',
body: JSON.stringify(body),
});
this._emit('session:created', data);
return data;
}
async getSession(sessionId) {
const data = await this._fetch(`/sessions/${sessionId}`);
this._emit('session:state', data);
return data;
}
async deleteSession(sessionId) {
const data = await this._fetch(`/sessions/${sessionId}`, { method: 'DELETE' });
this._emit('session:deleted', { session_id: sessionId });
return data;
}
async getEvents(sessionId) {
return this._fetch(`/sessions/${sessionId}/events`);
}
// --- Messages ---
async sendMessage(sessionId, message, stream = false) {
return this._fetch(`/sessions/${sessionId}/messages`, {
method: 'POST',
body: JSON.stringify({ message, stream }),
});
}
// --- SSE ---
subscribeSSE(sessionId) {
this.unsubscribeSSE(sessionId);
const url = `${BASE}/sessions/${sessionId}/stream`;
const es = new EventSource(url);
const eventTypes = [
'session.created', 'execution.started', 'agent.delta',
'tool.started', 'tool.completed', 'subagent.assigned',
'execution.completed', 'error', 'keepalive',
];
for (const type of eventTypes) {
es.addEventListener(type, (e) => {
try {
const payload = JSON.parse(e.data);
this._emit('sse', { type, ...payload });
this._emit(`sse:${type}`, payload);
} catch {
this._emit('sse', { type, raw: e.data });
}
});
}
es.onerror = () => {
this._emit('sse:error', { sessionId });
};
this._eventSources.set(sessionId, es);
return es;
}
unsubscribeSSE(sessionId) {
const es = this._eventSources.get(sessionId);
if (es) {
es.close();
this._eventSources.delete(sessionId);
}
}
unsubscribeAll() {
for (const [id, es] of this._eventSources) {
es.close();
}
this._eventSources.clear();
}
}
export const api = new ApiClient();

252
dashboard/js/app.js Normal file
View File

@@ -0,0 +1,252 @@
/**
* Application bootstrap — state management, health polling, routing
*/
import { api } from './api.js';
import { initSidebar, renderSessions } from './components/sidebar.js';
import { initSessionForm } from './components/session-form.js';
import { initToolbar, updateToolbar } from './components/toolbar.js';
import { initChat, addMessage, setStreamingContent, clearChat, setInputEnabled, showExecutionIndicator, hideExecutionIndicator } from './components/chat.js';
import { initEventLog, addEvent, clearEvents } from './components/event-log.js';
import { initInspector, updateInspector, clearInspector } from './components/session-inspector.js';
import { initTimeline, addTimelineStep, updateTimelineStep, clearTimeline } from './components/agent-timeline.js';
// --- Global state ---
export const state = {
sessions: JSON.parse(localStorage.getItem('agentic_sessions') || '[]'),
activeSessionId: null,
sessionState: null,
health: null,
isExecuting: false,
streamingContent: '',
currentAgent: '',
};
function saveSessions() {
localStorage.setItem('agentic_sessions', JSON.stringify(state.sessions));
}
// --- Session management ---
export async function selectSession(sessionId) {
api.unsubscribeAll();
state.activeSessionId = sessionId;
state.streamingContent = '';
state.currentAgent = '';
clearChat();
clearEvents();
clearTimeline();
clearInspector();
if (!sessionId) {
updateToolbar(null);
renderSessions();
return;
}
location.hash = `session=${sessionId}`;
renderSessions();
try {
const session = await api.getSession(sessionId);
state.sessionState = session;
updateToolbar(session);
updateInspector(session);
const events = await api.getEvents(sessionId);
for (const ev of events) addEvent(ev);
api.subscribeSSE(sessionId);
setInputEnabled(true);
} catch (e) {
addMessage('system', `Error loading session: ${e.message}`);
}
}
export async function createSession(body) {
const data = await api.createSession(body);
if (!state.sessions.includes(data.session_id)) {
state.sessions.unshift(data.session_id);
saveSessions();
}
await selectSession(data.session_id);
return data;
}
export async function deleteSession(sessionId) {
try {
await api.deleteSession(sessionId);
} catch { /* ignore 404 */ }
api.unsubscribeSSE(sessionId);
state.sessions = state.sessions.filter(s => s !== sessionId);
saveSessions();
if (state.activeSessionId === sessionId) {
state.activeSessionId = null;
state.sessionState = null;
location.hash = '';
clearChat();
clearEvents();
clearTimeline();
clearInspector();
updateToolbar(null);
}
renderSessions();
}
export async function sendMessage(message, stream = false) {
if (!state.activeSessionId || state.isExecuting) return;
state.isExecuting = true;
state.streamingContent = '';
setInputEnabled(false);
addMessage('user', message);
showExecutionIndicator();
try {
if (stream) {
await api.sendMessage(state.activeSessionId, message, true);
// Response comes via SSE — handled by event listeners
} else {
const result = await api.sendMessage(state.activeSessionId, message, false);
hideExecutionIndicator();
addMessage('assistant', result.content || JSON.stringify(result, null, 2));
state.isExecuting = false;
setInputEnabled(true);
// Refresh state
const session = await api.getSession(state.activeSessionId);
state.sessionState = session;
updateToolbar(session);
updateInspector(session);
}
} catch (e) {
hideExecutionIndicator();
addMessage('system', `Error: ${e.message}`);
state.isExecuting = false;
setInputEnabled(true);
}
}
// --- SSE event handlers ---
function setupSSEListeners() {
api.on('sse', (e) => {
addEvent(e.detail);
});
api.on('sse:execution.started', () => {
state.isExecuting = true;
showExecutionIndicator('Starting execution...');
});
api.on('sse:subagent.assigned', (e) => {
const d = e.detail.data || e.detail;
state.currentAgent = d.agent || '';
showExecutionIndicator(`Step ${d.step}/${d.total_steps}${d.agent}`);
addTimelineStep({
step: d.step,
totalSteps: d.total_steps,
agent: d.agent,
description: d.description,
status: 'executing',
});
});
api.on('sse:agent.delta', (e) => {
const d = e.detail.data || e.detail;
state.streamingContent += d.delta || '';
setStreamingContent(state.streamingContent, state.currentAgent);
});
api.on('sse:tool.started', (e) => {
const d = e.detail.data || e.detail;
showExecutionIndicator(`Running tool: ${d.tool}`);
});
api.on('sse:tool.completed', (e) => {
const d = e.detail.data || e.detail;
if (state.currentAgent) {
updateTimelineStep(state.currentAgent, { tool: d.tool, status: d.status });
}
});
api.on('sse:execution.completed', async (e) => {
hideExecutionIndicator();
if (state.streamingContent) {
// Finalize the streaming message
addMessage('assistant', state.streamingContent);
state.streamingContent = '';
}
state.isExecuting = false;
state.currentAgent = '';
setInputEnabled(true);
// Refresh session state
if (state.activeSessionId) {
try {
const session = await api.getSession(state.activeSessionId);
state.sessionState = session;
updateToolbar(session);
updateInspector(session);
} catch { /* ignore */ }
}
});
api.on('sse:error', (e) => {
const d = e.detail.data || e.detail;
addMessage('system', `Error: ${JSON.stringify(d)}`);
hideExecutionIndicator();
state.isExecuting = false;
setInputEnabled(true);
});
}
// --- Health polling ---
let healthInterval;
function startHealthPolling() {
const poll = async () => {
state.health = await api.checkHealth();
};
poll();
healthInterval = setInterval(poll, 10000);
}
// --- Theme ---
export function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('agentic_theme', next);
}
// --- Init ---
function init() {
// Restore theme
const savedTheme = localStorage.getItem('agentic_theme') || 'dark';
if (savedTheme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
// Init components
initToolbar();
initSidebar();
initSessionForm();
initChat();
initEventLog();
initInspector();
initTimeline();
// SSE listeners
setupSSEListeners();
// Health
startHealthPolling();
// Restore session from hash
const hash = location.hash;
const match = hash.match(/session=([a-f0-9]+)/);
if (match && state.sessions.includes(match[1])) {
selectSession(match[1]);
}
renderSessions();
}
document.addEventListener('DOMContentLoaded', init);

View File

@@ -0,0 +1,101 @@
/**
* Agent Timeline — visual execution timeline built from SSE events
*/
let containerEl;
let steps = [];
export function initTimeline() {
// The timeline renders inside the inspector; this module manages the data
containerEl = null;
steps = [];
}
export function addTimelineStep({ step, totalSteps, agent, description, status }) {
steps.push({
step,
totalSteps,
agent,
description,
status: status || 'executing',
tools: [],
});
renderTimeline();
}
export function updateTimelineStep(agent, { tool, status }) {
// Find the last step for this agent
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].agent === agent) {
if (tool) {
steps[i].tools.push({ name: tool, status: status || 'completed' });
}
break;
}
}
renderTimeline();
}
export function clearTimeline() {
steps = [];
renderTimeline();
}
function renderTimeline() {
// Find or create the timeline container in the inspector
const inspector = document.getElementById('inspector');
if (!inspector) return;
// Remove existing timeline section
let existing = document.getElementById('live-timeline-section');
if (existing) existing.remove();
if (steps.length === 0) return;
const section = document.createElement('div');
section.className = 'inspector-section';
section.id = 'live-timeline-section';
let html = '<div class="inspector-section-title">Live Timeline</div>';
html += '<div class="timeline">';
for (const s of steps) {
const stepClass = s.status === 'executing' ? 'active' : s.status === 'completed' ? 'completed' : s.status === 'failed' ? 'failed' : '';
html += `
<div class="timeline-step ${stepClass}">
<div class="timeline-step-header">
<span class="role-badge ${s.agent}">${s.agent}</span>
<span class="text-sm text-muted">Step ${s.step}/${s.totalSteps}</span>
</div>
<div class="timeline-step-desc">${escapeHtml(s.description)}</div>
`;
if (s.tools.length > 0) {
html += '<div class="timeline-tools">';
for (const t of s.tools) {
html += `<span class="tool-chip">${escapeHtml(t.name)}</span>`;
}
html += '</div>';
}
html += '</div>';
}
html += '</div>';
section.innerHTML = html;
// Insert at the top of inspector (after session info if present)
const firstSection = inspector.querySelector('.inspector-section');
if (firstSection && firstSection.nextSibling) {
inspector.insertBefore(section, firstSection.nextSibling);
} else {
inspector.appendChild(section);
}
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

View File

@@ -0,0 +1,180 @@
/**
* Chat — message input + conversation view + streaming
*/
import { sendMessage } from '../app.js';
let messagesEl;
let inputEl;
let sendBtn;
let streamBtn;
let indicatorEl;
let streamingBubble = null;
// Minimal markdown renderer
function renderMarkdown(text) {
let html = text
// Code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Headers
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Lists
.replace(/^\- (.+)$/gm, '<li>$1</li>')
.replace(/^\* (.+)$/gm, '<li>$1</li>')
// Paragraphs (double newlines)
.replace(/\n\n/g, '</p><p>')
// Single newlines
.replace(/\n/g, '<br>');
// Wrap loose <li> in <ul>
html = html.replace(/(<li>.*?<\/li>)/gs, '<ul>$1</ul>');
html = html.replace(/<\/ul>\s*<ul>/g, '');
return `<p>${html}</p>`;
}
export function initChat() {
const main = document.getElementById('main');
main.innerHTML = `
<div class="chat-messages" id="chat-messages">
<div class="chat-empty">
<div class="icon">&#9881;</div>
<p>Select or create a session to start</p>
</div>
</div>
<div class="event-log-panel" id="event-log-panel"></div>
<div class="chat-input-area">
<div class="chat-input-wrapper">
<textarea id="chat-input" placeholder="Send a message..." rows="1" disabled></textarea>
<button class="btn btn-primary" id="btn-send" disabled>Send</button>
<button class="btn" id="btn-stream" disabled title="Send with SSE streaming">Stream</button>
</div>
</div>
`;
messagesEl = document.getElementById('chat-messages');
inputEl = document.getElementById('chat-input');
sendBtn = document.getElementById('btn-send');
streamBtn = document.getElementById('btn-stream');
// Auto-resize textarea
inputEl.addEventListener('input', () => {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
});
// Send
sendBtn.addEventListener('click', () => doSend(false));
streamBtn.addEventListener('click', () => doSend(true));
// Keyboard shortcuts
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
doSend(false);
}
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
doSend(true);
}
});
}
function doSend(stream) {
const text = inputEl.value.trim();
if (!text) return;
inputEl.value = '';
inputEl.style.height = 'auto';
sendMessage(text, stream);
}
export function addMessage(role, content) {
// Remove empty state
const empty = messagesEl.querySelector('.chat-empty');
if (empty) empty.remove();
// Remove streaming bubble if finalizing
if (role === 'assistant' && streamingBubble) {
streamingBubble.remove();
streamingBubble = null;
}
const div = document.createElement('div');
div.className = `message ${role}`;
if (role === 'assistant') {
div.innerHTML = renderMarkdown(content);
} else if (role === 'system') {
div.textContent = content;
} else {
div.textContent = content;
}
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
export function setStreamingContent(content, agent) {
// Remove empty state
const empty = messagesEl.querySelector('.chat-empty');
if (empty) empty.remove();
if (!streamingBubble) {
streamingBubble = document.createElement('div');
streamingBubble.className = 'message assistant';
messagesEl.appendChild(streamingBubble);
}
let badgeHtml = '';
if (agent) {
badgeHtml = `<span class="agent-badge role-badge ${agent}">${agent}</span><br>`;
}
streamingBubble.innerHTML = badgeHtml + renderMarkdown(content);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
export function clearChat() {
if (!messagesEl) return;
streamingBubble = null;
messagesEl.innerHTML = `
<div class="chat-empty">
<div class="icon">&#9881;</div>
<p>Select or create a session to start</p>
</div>
`;
}
export function setInputEnabled(enabled) {
if (!inputEl) return;
inputEl.disabled = !enabled;
sendBtn.disabled = !enabled;
streamBtn.disabled = !enabled;
if (enabled) inputEl.focus();
}
export function showExecutionIndicator(text) {
hideExecutionIndicator();
const empty = messagesEl.querySelector('.chat-empty');
if (empty) empty.remove();
indicatorEl = document.createElement('div');
indicatorEl.className = 'execution-indicator';
indicatorEl.innerHTML = `<div class="spinner"></div><span>${text || 'Executing...'}</span>`;
messagesEl.appendChild(indicatorEl);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
export function hideExecutionIndicator() {
if (indicatorEl) {
indicatorEl.remove();
indicatorEl = null;
}
}

View File

@@ -0,0 +1,171 @@
/**
* Event Log — real-time SSE events with filtering
*/
const EVENT_CATEGORIES = {
'session.created': 'lifecycle',
'execution.started': 'lifecycle',
'execution.completed': 'lifecycle',
'agent.delta': 'content',
'tool.started': 'tool',
'tool.completed': 'tool',
'subagent.assigned': 'orchestration',
'error': 'error',
'keepalive': 'keepalive',
};
const CATEGORY_LABELS = ['lifecycle', 'content', 'tool', 'orchestration', 'error'];
let entriesEl;
let activeFilters = new Set(CATEGORY_LABELS);
let stickToBottom = true;
let events = [];
export function initEventLog() {
const panel = document.getElementById('event-log-panel');
panel.innerHTML = `
<div class="event-log-header" id="event-log-toggle">
<span class="chevron">&#9660;</span>
<span>Event Log</span>
<span class="toolbar-spacer"></span>
<span class="text-muted text-sm" id="event-count">0 events</span>
<button class="btn btn-sm" id="btn-clear-events" style="margin-left:8px">Clear</button>
<button class="btn btn-sm" id="btn-export-events">Export</button>
</div>
<div class="event-log-filters" id="event-filters"></div>
<div class="event-log-entries" id="event-entries"></div>
`;
entriesEl = document.getElementById('event-entries');
// Toggle panel
document.getElementById('event-log-toggle').addEventListener('click', () => {
panel.classList.toggle('open');
});
// Filters
const filtersEl = document.getElementById('event-filters');
for (const cat of CATEGORY_LABELS) {
const btn = document.createElement('button');
btn.className = `event-filter-btn active`;
btn.textContent = cat;
btn.dataset.category = cat;
btn.addEventListener('click', () => {
if (activeFilters.has(cat)) {
activeFilters.delete(cat);
btn.classList.remove('active');
} else {
activeFilters.add(cat);
btn.classList.add('active');
}
renderEvents();
});
filtersEl.appendChild(btn);
}
// Auto-scroll detection
entriesEl.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = entriesEl;
stickToBottom = scrollHeight - scrollTop - clientHeight < 20;
});
// Clear
document.getElementById('btn-clear-events').addEventListener('click', (e) => {
e.stopPropagation();
clearEvents();
});
// Export
document.getElementById('btn-export-events').addEventListener('click', (e) => {
e.stopPropagation();
const blob = new Blob([JSON.stringify(events, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `events-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
});
}
export function addEvent(event) {
events.push(event);
document.getElementById('event-count').textContent = `${events.length} events`;
const type = event.type || '';
const category = EVENT_CATEGORIES[type] || 'lifecycle';
if (!activeFilters.has(category)) return;
if (category === 'keepalive') return; // Hide keepalives by default
const entry = createEventEntry(event, type, category);
entriesEl.appendChild(entry);
if (stickToBottom) {
entriesEl.scrollTop = entriesEl.scrollHeight;
}
}
function createEventEntry(event, type, category) {
const el = document.createElement('div');
el.className = 'event-entry';
const time = event.timestamp
? new Date(event.timestamp).toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 3 })
: '--:--:--';
const data = event.data || {};
const summary = summarizeEventData(type, data);
el.innerHTML = `
<span class="event-time">${time}</span>
<span class="event-type-badge ${category}">${type.split('.').pop()}</span>
<span class="event-data" title="Click to expand">${summary}</span>
`;
// Toggle expand
const dataEl = el.querySelector('.event-data');
dataEl.addEventListener('click', () => {
if (dataEl.classList.contains('expanded')) {
dataEl.classList.remove('expanded');
dataEl.textContent = summary;
} else {
dataEl.classList.add('expanded');
dataEl.textContent = JSON.stringify(data, null, 2);
}
});
return el;
}
function summarizeEventData(type, data) {
switch (type) {
case 'agent.delta': return `[${data.agent || '?'}] "${(data.delta || '').substring(0, 60)}..."`;
case 'tool.started': return `Tool: ${data.tool}`;
case 'tool.completed': return `Tool: ${data.tool}${data.status}`;
case 'subagent.assigned': return `Step ${data.step}/${data.total_steps}: ${data.agent}${(data.description || '').substring(0, 50)}`;
case 'execution.started': return `Session: ${(data.session_id || '').substring(0, 12)}`;
case 'execution.completed': return `Steps: ${data.steps_completed}${data.status}`;
case 'error': return JSON.stringify(data);
default: return JSON.stringify(data).substring(0, 80);
}
}
function renderEvents() {
entriesEl.innerHTML = '';
for (const event of events) {
const type = event.type || '';
const category = EVENT_CATEGORIES[type] || 'lifecycle';
if (!activeFilters.has(category)) continue;
if (category === 'keepalive') continue;
entriesEl.appendChild(createEventEntry(event, type, category));
}
}
export function clearEvents() {
events = [];
if (entriesEl) entriesEl.innerHTML = '';
const countEl = document.getElementById('event-count');
if (countEl) countEl.textContent = '0 events';
}

View File

@@ -0,0 +1,147 @@
/**
* Session creation modal
*/
import { createSession } from '../app.js';
let overlay;
export function initSessionForm() {
overlay = document.getElementById('modal-overlay');
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<h2>New Session</h2>
<button class="btn-icon" id="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Project Profile (JSON)</label>
<textarea id="field-profile" spellcheck="false">{
"name": "",
"tech_stack": [],
"description": ""
}</textarea>
<div class="error-text hidden" id="err-profile"></div>
</div>
<div class="form-group">
<label>Immutable Rules</label>
<div class="rules-list" id="rules-list">
<div class="rule-row">
<input type="text" placeholder="Add a rule..." />
<button class="btn btn-sm" id="btn-add-rule">+</button>
</div>
</div>
</div>
<div class="form-group">
<label>Metadata (JSON)</label>
<textarea id="field-metadata" spellcheck="false">{}</textarea>
<div class="error-text hidden" id="err-metadata"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn" id="btn-cancel">Cancel</button>
<button class="btn btn-primary" id="btn-create">Create Session</button>
</div>
</div>
`;
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('btn-cancel').addEventListener('click', closeModal);
document.getElementById('btn-create').addEventListener('click', handleCreate);
document.getElementById('btn-add-rule').addEventListener('click', addRuleRow);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal();
});
// Format JSON on blur
for (const id of ['field-profile', 'field-metadata']) {
document.getElementById(id).addEventListener('blur', (e) => {
try {
const parsed = JSON.parse(e.target.value);
e.target.value = JSON.stringify(parsed, null, 2);
e.target.nextElementSibling.classList.add('hidden');
} catch (err) {
e.target.nextElementSibling.textContent = `Invalid JSON: ${err.message}`;
e.target.nextElementSibling.classList.remove('hidden');
}
});
}
}
function addRuleRow() {
const list = document.getElementById('rules-list');
const addBtn = document.getElementById('btn-add-rule');
const row = document.createElement('div');
row.className = 'rule-row';
row.innerHTML = `
<input type="text" placeholder="Add a rule..." />
<button class="btn btn-sm btn-danger remove-rule">&minus;</button>
`;
row.querySelector('.remove-rule').addEventListener('click', () => row.remove());
list.insertBefore(row, addBtn.closest('.rule-row'));
}
function getRules() {
const inputs = document.querySelectorAll('#rules-list input');
return Array.from(inputs).map(i => i.value.trim()).filter(Boolean);
}
function validateJSON(id) {
const el = document.getElementById(id);
try {
return JSON.parse(el.value);
} catch (err) {
const errEl = document.getElementById(`err-${id.replace('field-', '')}`);
errEl.textContent = `Invalid JSON: ${err.message}`;
errEl.classList.remove('hidden');
return null;
}
}
async function handleCreate() {
const profile = validateJSON('field-profile');
const metadata = validateJSON('field-metadata');
if (profile === null || metadata === null) return;
const rules = getRules();
const btn = document.getElementById('btn-create');
btn.disabled = true;
btn.textContent = 'Creating...';
try {
await createSession({
project_profile: profile,
immutable_rules: rules,
metadata,
});
closeModal();
resetForm();
} catch (e) {
alert(`Failed: ${e.message}`);
} finally {
btn.disabled = false;
btn.textContent = 'Create Session';
}
}
function resetForm() {
document.getElementById('field-profile').value = '{\n "name": "",\n "tech_stack": [],\n "description": ""\n}';
document.getElementById('field-metadata').value = '{}';
const list = document.getElementById('rules-list');
const rows = list.querySelectorAll('.rule-row');
rows.forEach((r, i) => { if (i < rows.length - 1) r.remove(); });
const lastInput = list.querySelector('input');
if (lastInput) lastInput.value = '';
}
export function openSessionForm() {
overlay.classList.add('open');
}
function closeModal() {
overlay.classList.remove('open');
}

View File

@@ -0,0 +1,144 @@
/**
* Session Inspector — right panel with full state rendering
*/
let inspectorEl;
export function initInspector() {
inspectorEl = document.getElementById('inspector');
clearInspector();
}
export function clearInspector() {
if (!inspectorEl) return;
inspectorEl.innerHTML = '<div class="inspector-empty">No session selected</div>';
}
export function updateInspector(session) {
if (!inspectorEl || !session) return;
inspectorEl.innerHTML = '';
// Header
inspectorEl.appendChild(buildSection('Session', `
<div class="inspector-field">
<span class="label">ID</span>
<span class="value truncate" style="max-width:180px" title="${session.session_id}">${session.session_id.substring(0, 16)}...</span>
</div>
<div class="inspector-field">
<span class="label">Status</span>
<span class="badge ${session.status}">${session.status}</span>
</div>
<div class="inspector-field">
<span class="label">Turns</span>
<span class="value">${session.turn_count}</span>
</div>
<div class="inspector-field">
<span class="label">Created</span>
<span class="value">${formatTime(session.created_at)}</span>
</div>
<div class="inspector-field">
<span class="label">Updated</span>
<span class="value">${formatTime(session.updated_at)}</span>
</div>
`));
// Current Task
if (session.current_task) {
const task = session.current_task;
let taskHtml = `
<div class="inspector-field">
<span class="label">Objective</span>
</div>
<div class="text-sm" style="margin-bottom:6px;color:var(--text-primary)">${escapeHtml(task.objective)}</div>
<div class="inspector-field">
<span class="label">Status</span>
<span class="badge ${task.status}">${task.status}</span>
</div>
<div class="inspector-field">
<span class="label">Step</span>
<span class="value">${task.current_step_index + 1} / ${(task.plan || []).length}</span>
</div>
`;
// Facts
if (task.facts_extracted && task.facts_extracted.length > 0) {
taskHtml += '<div class="mt-2"><span class="label text-sm">Facts:</span><ul style="padding-left:16px;margin-top:4px">';
for (const f of task.facts_extracted.slice(-8)) {
taskHtml += `<li class="text-sm">${escapeHtml(f)}</li>`;
}
taskHtml += '</ul></div>';
}
// Constraints
if (task.constraints && task.constraints.length > 0) {
taskHtml += '<div class="mt-2"><span class="label text-sm">Constraints:</span><ul style="padding-left:16px;margin-top:4px">';
for (const c of task.constraints) {
taskHtml += `<li class="text-sm">${escapeHtml(c)}</li>`;
}
taskHtml += '</ul></div>';
}
inspectorEl.appendChild(buildSection('Current Task', taskHtml));
// Plan
if (task.plan && task.plan.length > 0) {
let planHtml = '<div class="timeline">';
for (let i = 0; i < task.plan.length; i++) {
const step = task.plan[i];
const stepStatus = step.status || 'pending';
const isActive = i === task.current_step_index && task.status === 'executing';
const stepClass = isActive ? 'active' : (stepStatus === 'completed' ? 'completed' : stepStatus === 'failed' ? 'failed' : '');
planHtml += `
<div class="timeline-step ${stepClass}">
<div class="timeline-step-header">
<span class="role-badge ${step.agent_role || 'coder'}">${step.agent_role || 'coder'}</span>
<span class="badge ${stepStatus}">${stepStatus}</span>
</div>
<div class="timeline-step-desc">${escapeHtml(step.description)}</div>
${step.tools_used && step.tools_used.length > 0 ? `
<div class="timeline-tools">
${step.tools_used.map(t => `<span class="tool-chip">${escapeHtml(t)}</span>`).join('')}
</div>
` : ''}
${step.result_summary ? `<div class="text-sm text-muted mt-2">${escapeHtml(step.result_summary.substring(0, 200))}</div>` : ''}
</div>
`;
}
planHtml += '</div>';
inspectorEl.appendChild(buildSection('Execution Plan', planHtml));
}
}
// Completed tasks
if (session.completed_tasks && session.completed_tasks.length > 0) {
let html = '<ul style="padding-left:16px">';
for (const t of session.completed_tasks) {
html += `<li class="text-sm mono">${t}</li>`;
}
html += '</ul>';
inspectorEl.appendChild(buildSection(`Completed (${session.completed_tasks.length})`, html));
}
}
function buildSection(title, innerHtml) {
const section = document.createElement('div');
section.className = 'inspector-section';
section.innerHTML = `<div class="inspector-section-title">${title}</div>${innerHtml}`;
return section;
}
function formatTime(isoStr) {
if (!isoStr) return '—';
try {
return new Date(isoStr).toLocaleTimeString('en-US', { hour12: false });
} catch {
return isoStr;
}
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View File

@@ -0,0 +1,67 @@
/**
* Sidebar — session list + health indicator
*/
import { state, selectSession, deleteSession, createSession } from '../app.js';
import { api } from '../api.js';
import { openSessionForm } from './session-form.js';
let listEl;
let healthDot;
let healthText;
export function initSidebar() {
const sidebar = document.getElementById('sidebar');
sidebar.innerHTML = `
<div class="sidebar-header">
<h3>Sessions</h3>
<button class="btn btn-sm btn-primary" id="btn-new-session">+ New</button>
</div>
<div class="session-list" id="session-list"></div>
<div class="sidebar-footer">
<span class="status-dot disconnected" id="health-dot"></span>
<span id="health-text">Connecting...</span>
</div>
`;
listEl = document.getElementById('session-list');
healthDot = document.getElementById('health-dot');
healthText = document.getElementById('health-text');
document.getElementById('btn-new-session').addEventListener('click', openSessionForm);
// Health updates
api.on('health', (e) => {
const ok = e.detail.status === 'ok';
healthDot.className = `status-dot ${ok ? 'connected' : 'disconnected'}`;
healthText.textContent = ok ? 'Connected' : 'Disconnected';
});
}
export function renderSessions() {
if (!listEl) return;
listEl.innerHTML = '';
for (const sid of state.sessions) {
const item = document.createElement('div');
item.className = `session-item${sid === state.activeSessionId ? ' active' : ''}`;
item.innerHTML = `
<span class="status-dot idle"></span>
<span class="truncate">${sid.substring(0, 12)}...</span>
<button class="delete-btn" title="Delete session">&times;</button>
`;
item.querySelector('.truncate').addEventListener('click', () => selectSession(sid));
item.querySelector('.delete-btn').addEventListener('click', (e) => {
e.stopPropagation();
if (confirm('Delete this session?')) deleteSession(sid);
});
listEl.appendChild(item);
}
if (state.sessions.length === 0) {
listEl.innerHTML = '<div class="text-muted text-sm" style="padding:20px 10px;text-align:center">No sessions yet</div>';
}
}

View File

@@ -0,0 +1,199 @@
/**
* Toolbar — top bar with session info and actions
*/
import { state, deleteSession, toggleTheme } from '../app.js';
import { api } from '../api.js';
let sessionIdEl;
let statusBadge;
export function initToolbar() {
const toolbar = document.getElementById('toolbar');
toolbar.innerHTML = `
<span class="toolbar-brand">Agentic Microservice</span>
<span class="toolbar-separator"></span>
<span class="toolbar-session-id" id="toolbar-session-id" title="Click to copy">No session</span>
<span class="badge idle" id="toolbar-status" style="display:none"></span>
<span class="toolbar-spacer"></span>
<div class="toolbar-actions">
<button class="btn btn-sm" id="btn-refresh" title="Refresh state">&#8635; Refresh</button>
<button class="btn btn-sm" id="btn-context-debug" title="View context debug">&#128270; Context</button>
<button class="btn btn-sm" id="btn-raw-state" title="View raw JSON">{ } Raw</button>
<button class="btn btn-sm" id="btn-theme" title="Toggle theme">&#9681;</button>
<button class="btn btn-sm btn-danger" id="btn-delete-session" title="Delete session">&#128465;</button>
</div>
`;
sessionIdEl = document.getElementById('toolbar-session-id');
statusBadge = document.getElementById('toolbar-status');
// Copy session ID
sessionIdEl.addEventListener('click', () => {
if (state.activeSessionId) {
navigator.clipboard.writeText(state.activeSessionId);
const orig = sessionIdEl.textContent;
sessionIdEl.textContent = 'Copied!';
setTimeout(() => { sessionIdEl.textContent = orig; }, 1000);
}
});
// Refresh
document.getElementById('btn-refresh').addEventListener('click', async () => {
if (!state.activeSessionId) return;
try {
const session = await api.getSession(state.activeSessionId);
state.sessionState = session;
updateToolbar(session);
// Dispatch event so inspector updates too
const { updateInspector } = await import('./session-inspector.js');
updateInspector(session);
} catch (e) {
alert(`Refresh failed: ${e.message}`);
}
});
// Context debug
document.getElementById('btn-context-debug').addEventListener('click', async () => {
if (!state.activeSessionId) return;
try {
const res = await fetch(`/api/v1/sessions/${state.activeSessionId}/context-debug`);
const data = await res.json();
const win = window.open('', '_blank', 'width=900,height=700');
win.document.title = 'Context Debug';
win.document.write(renderContextDebugHTML(data));
} catch (e) {
alert(`Failed: ${e.message}`);
}
});
// Raw state
document.getElementById('btn-raw-state').addEventListener('click', async () => {
if (!state.activeSessionId) return;
try {
const session = await api.getSession(state.activeSessionId);
const win = window.open('', '_blank', 'width=600,height=500');
win.document.write(`<pre style="font-size:12px;padding:16px;background:#0d1117;color:#e6edf3;font-family:monospace">${JSON.stringify(session, null, 2)}</pre>`);
} catch (e) {
alert(`Failed: ${e.message}`);
}
});
// Theme
document.getElementById('btn-theme').addEventListener('click', toggleTheme);
// Delete
document.getElementById('btn-delete-session').addEventListener('click', () => {
if (!state.activeSessionId) return;
if (confirm('Delete this session permanently?')) {
deleteSession(state.activeSessionId);
}
});
}
export function updateToolbar(session) {
if (!session) {
sessionIdEl.textContent = 'No session';
statusBadge.style.display = 'none';
return;
}
sessionIdEl.textContent = session.session_id;
statusBadge.textContent = session.status;
statusBadge.className = `badge ${session.status}`;
statusBadge.style.display = '';
}
function renderContextDebugHTML(data) {
const css = `
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #e6edf3; padding: 24px; font-size: 14px; }
h1 { font-size: 18px; margin-bottom: 16px; color: #58a6ff; }
h2 { font-size: 15px; margin: 20px 0 10px; color: #bc8cff; border-bottom: 1px solid #30363d; padding-bottom: 6px; }
h3 { font-size: 13px; margin: 12px 0 6px; color: #d29922; }
.meta { color: #8b949e; font-size: 12px; margin-bottom: 16px; }
.build { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
.build-header { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 600; }
.tag.agent { background: rgba(63,185,80,.15); color: #3fb950; }
.tag.tokens { background: rgba(88,166,255,.15); color: #58a6ff; }
.tag.sections { background: rgba(188,140,255,.15); color: #bc8cff; }
.tag.compacted { background: rgba(248,81,73,.15); color: #f85149; }
.section-row { display: grid; grid-template-columns: 140px 60px 60px 1fr; gap: 8px; padding: 6px 8px; border-radius: 4px; font-size: 12px; align-items: start; }
.section-row:nth-child(odd) { background: rgba(255,255,255,.02); }
.section-row .type { color: #58a6ff; font-weight: 600; font-family: 'SF Mono', monospace; }
.section-row .num { color: #8b949e; text-align: right; font-family: 'SF Mono', monospace; }
.section-row .preview { color: #8b949e; font-size: 11px; line-height: 1.4; word-break: break-all; }
.section-header { display: grid; grid-template-columns: 140px 60px 60px 1fr; gap: 8px; padding: 4px 8px; font-size: 11px; color: #6e7681; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid #21262d; margin-bottom: 4px; }
.bar { height: 6px; border-radius: 3px; margin-top: 8px; background: #21262d; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 3px; }
.user-msg { background: #1c2128; border: 1px solid #30363d; border-radius: 6px; padding: 10px 14px; font-size: 12px; font-family: 'SF Mono', monospace; color: #e6edf3; margin-top: 8px; white-space: pre-wrap; word-break: break-word; }
.empty { color: #6e7681; font-style: italic; padding: 40px; text-align: center; }
</style>
`;
if (!data.history || data.history.length === 0) {
return css + '<body><h1>Context Debug</h1><div class="empty">No context builds yet. Send a message first.</div></body>';
}
let html = css + '<body>';
html += '<h1>Context Engine Debug</h1>';
html += '<div class="meta">Session: ' + data.session_id + ' &mdash; ' + data.total_builds + ' context build(s)</div>';
// Reverse to show most recent first
const builds = [...data.history].reverse();
for (let i = 0; i < builds.length; i++) {
const b = builds[i];
const time = new Date(b.timestamp * 1000).toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 2 });
const maxTokens = 120000;
const pct = Math.min(100, (b.total_tokens / maxTokens) * 100);
const barColor = pct > 80 ? '#f85149' : pct > 50 ? '#d29922' : '#3fb950';
html += '<div class="build">';
html += '<div class="build-header">';
html += '<span class="tag agent">' + b.agent + '</span>';
html += '<span class="tag tokens">~' + b.total_tokens.toLocaleString() + ' tokens</span>';
html += '<span class="tag sections">' + b.sections_count + ' sections</span>';
if (b.compacted) html += '<span class="tag compacted">COMPACTED</span>';
html += '<span style="color:#6e7681;font-size:11px;margin-left:auto">' + time + '</span>';
html += '</div>';
// Token usage bar
html += '<div class="bar"><div class="bar-fill" style="width:' + pct.toFixed(1) + '%;background:' + barColor + '"></div></div>';
html += '<div style="font-size:11px;color:#6e7681;margin-top:2px">' + pct.toFixed(1) + '% of context window (' + b.total_tokens.toLocaleString() + ' / ' + maxTokens.toLocaleString() + ')</div>';
// Sections table
html += '<h3>Sections</h3>';
html += '<div class="section-header"><span>Type</span><span style="text-align:right">Tokens</span><span style="text-align:right">Prio</span><span>Preview</span></div>';
for (const s of b.sections) {
html += '<div class="section-row">';
html += '<span class="type">' + s.type + '</span>';
html += '<span class="num">' + s.tokens + '</span>';
html += '<span class="num">' + s.priority + '</span>';
html += '<span class="preview">' + escapeHtml(s.preview) + '</span>';
html += '</div>';
}
// User message
html += '<h3>User Message Sent to Model</h3>';
html += '<div class="user-msg">' + escapeHtml(b.user_message_preview) + '</div>';
// Extra info
html += '<div style="margin-top:10px;font-size:11px;color:#6e7681">';
html += 'Artifacts: ' + b.artifacts_count + ' | Working items: ' + b.working_items_count + ' | System prompt tokens: ' + b.system_prompt_tokens;
html += '</div>';
html += '</div>';
}
html += '</body>';
return html;
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

33
docker-compose.yml Normal file
View File

@@ -0,0 +1,33 @@
services:
redis:
image: redis:7-alpine
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
agentic:
build: .
ports:
- "8000:8000"
environment:
AGENTIC_REDIS_HOST: redis
AGENTIC_REDIS_PORT: 6379
AGENTIC_REDIS_DB: ${REDIS_DB:-0}
AGENTIC_REDIS_PASSWORD: ${REDIS_PASSWORD:-}
AGENTIC_ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
AGENTIC_OPENAI_API_KEY: ${OPENAI_API_KEY:-}
AGENTIC_DEFAULT_MODEL_PROVIDER: ${AGENTIC_DEFAULT_MODEL_PROVIDER:-claude}
AGENTIC_DEBUG: "false"
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
volumes:
redis-data:

143
docs/ACAI-CLAUDE.md Normal file
View File

@@ -0,0 +1,143 @@
# Acai CMS — Project Instructions
This is an Acai CMS website project. Follow these instructions when working with the codebase.
## Environment
- The site runs in Docker, typically at **http://localhost:8080**
- You can make HTTP requests to test pages, APIs, or form submissions
- If you need to inspect the live site, use browser tools (Playwright MCP) or HTTP requests to localhost:8080
## Project Structure
```
.
├── template/estandar/
│ ├── modulos/ # Builder modules (visual components)
│ │ └── <module-id>/
│ │ ├── index-base.tpl # Twig template (source — EDIT THIS)
│ │ ├── style.css # Module styles
│ │ └── script.js # Module JavaScript
│ │ ├── index.tpl # Compiled (auto-generated, do NOT edit)
│ │ ├── index-twig.tpl # Compiled (auto-generated, do NOT edit)
│ │ └── builder.json # Compiled builder vars (auto-generated, do NOT edit)
│ ├── css/ # Global CSS
│ └── js/ # Global JavaScript
├── hooks/ # PHP hooks (server-side logic)
├── cms/
│ ├── data/schema/ # Database table schemas (JSON)
│ ├── lib/plugins/ # CMS plugins
│ └── uploads/ # Uploaded media files
├── .acai # Project config (domain, tokens, DB credentials)
├── .docker/
│ ├── .env # Docker environment (DB credentials)
│ ├── docker-compose.yml
│ ├── tunnel-url.txt # Public tunnel URL (if active)
│ └── bore-db-url.txt # Database tunnel URL (if active)
└── database.sql # Database dump
```
## Key Concepts
### Modules (`template/estandar/modulos/`)
Visual components that the site builder uses. Each module is a self-contained unit with its own template (Twig + Acai attributes), CSS, and JS. Modules are placed on pages via the drag-and-drop builder. The editable file is always `index-base.tpl`.
- Include other modules: `<module_id :param1="value1"></module_id>`
- Each module instance gets a unique `section_id` variable for anchors/scoping
- Use `interno` variable to detect CMS editor mode vs public view
See [docs/modular-system.md](docs/modular-system.md) for detailed rules.
### Pages
Every record with an `enlace` field is a page. Pages are either **Builder** (modular) or **Standard**:
- **Builder**: `controlador` = `cms/lib/plugins/builder_saas/controlador.php` — content via modules
- **Standard**: `controlador` = `cms/lib/plugins/builder_saas/controlador_tabla.php` — content in record fields
**Critical**: Never change `enlace` or `controlador` of existing pages unless explicitly asked.
See [docs/pages-and-records.md](docs/pages-and-records.md) for full details.
### General Sections
Database-backed templates (headers, footers, record views) that use the `thisrecord` variable to access record fields. They use the same Twig + Acai attribute engine as modules.
- Upload fields return arrays: `thisrecord.image[0].urlPath`
- Foreign keys use `_num` suffix: `category_num`
See [docs/modular-system.md](docs/modular-system.md) for details.
### Hooks (`hooks/`)
PHP files that execute server-side logic. Triggered by:
- Twig filter: `'hooks/module_id/' | hook({param: value})`
- HTML tag: `<hook result="var" endpoint="/hooks/module_id/" :param="value"></hook>`
- JavaScript: `CmsApi.hook('/hooks/module_id/', {param: value}, callback)`
- Form action: via `c-form` attribute
See [docs/hooks-and-api.md](docs/hooks-and-api.md) for usage.
## Database Access
When the site is running in Docker, you can connect to the database:
- **Host:** `127.0.0.1`
- **Port:** Check `.docker/docker-compose.yml` for the mapped port (usually 3307+)
- **Credentials:** Read from `.docker/.env`:
- `DB_USERNAME`
- `DB_PASSWORD`
- `DB_DATABASE`
```bash
docker exec -it dw-<project-name>-db mysql -u root -p<password> <database>
```
**Important:** Table names in CmsApi/Twig do NOT use the `cms_` prefix. The primary key is always `num`, never `id`.
## Acai Core (web-base)
The project workspace contains only the **customization layer** (modules, hooks, schemas, uploads). The CMS core (routing, rendering engine, admin panel, APIs) lives in a separate directory called **web-base** that is mounted as a Docker volume.
The web-base path can be obtained via: `GET http://localhost:9090/api/web-base-path`
Do NOT modify web-base files — they are shared across all projects.
## Critical Rules
1. **Before working with any area (hooks, modules, templates, CSS/JS, etc.), read the corresponding documentation in `docs/` first.** Do not guess or assume — always consult the docs before taking action.
2. **NEVER use `mkdir` to create directories.** Instead, use the `Write` tool to create the first file inside the directory — this creates parent directories automatically. For example, to create a new module, directly write the `index-base.tpl` file.
3. Only edit `index-base.tpl` in modules — `index.tpl`, `index-twig.tpl`, and `builder.json` are auto-generated
3. **After editing any `index-base.tpl`, ALWAYS call the `compile_module` MCP tool** to compile the module/section. This is mandatory — without compilation, changes won't take effect in the CMS.
4. Use Twig **filters** (with `|`), never Twig functions
5. Table names without `cms_` prefix everywhere
6. Primary key is `num`, never `id`
7. Upload fields are arrays — access with `[0].urlPath`
8. Tailwind CSS as primary styling, custom CSS scoped with BEM when needed
9. Twig concatenation uses `~` operator: `'value=' ~ variable`
10. `enlace` (link) fields already include slashes — **NEVER modify an existing enlace** unless explicitly asked
11. **NEVER modify the `controlador` field** of existing records — it defines whether a page is Builder or Standard
## MCP Tools
This project has MCP tools for managing modules, records, media, and more. **Before starting any task, consult the tools reference for the correct workflow.**
See [docs/mcp-tools-reference.md](docs/mcp-tools-reference.md) for the complete list of available tools and step-by-step workflows.
Key workflows:
- **Create module**: Read [docs/module-creation-guide.md](docs/module-creation-guide.md) first → `create_module``add_module_to_record` (returns sectionId) → `set_module_config_vars` (returns uploadFields) → images via uploadFields
- **Edit module**: read vars → edit `index-base.tpl``compile_module`
- **Add images**: use `uploadFields` from `set_module_config_vars` response → `upload_record_image`
- **Generate images**: `generate_image``upload_record_image` with returned URL
## Documentation
- [docs/modular-system.md](docs/modular-system.md) — Modules, general sections, global variables
- [docs/builder-fields.md](docs/builder-fields.md) — Builder field types, Acai attributes, c-form, components
- [docs/twig-filters.md](docs/twig-filters.md) — Twig filters reference (get, hook, module, queryDB, etc.)
- [docs/hooks-and-api.md](docs/hooks-and-api.md) — PHP hooks, CmsApi, CocoDB, record creation
- [docs/css-js-conventions.md](docs/css-js-conventions.md) — CSS/JS/Vue 3, Tailwind, BEM, native components
- [docs/quick-reference.md](docs/quick-reference.md) — Cheat sheet: domain rules, field types, filters
- [docs/production-patterns.md](docs/production-patterns.md) — Real production patterns (header, zigzag, FAQ, forms)
- [docs/vue-builder-rules.md](docs/vue-builder-rules.md) — CMS-VUE rules (tabs, colorpicker, components)
- [docs/vue-builder-examples.md](docs/vue-builder-examples.md) — Vue builder examples (Banner Slideshow, etc.)
- [docs/pages-and-records.md](docs/pages-and-records.md) — Page types (Builder vs Standard), sections, visibility, critical rules
- [docs/module-creation-guide.md](docs/module-creation-guide.md) — Module creation workflow, style reference, field types
- [docs/mcp-tools-reference.md](docs/mcp-tools-reference.md) — MCP tools reference, available tools, workflows

474
docs/builder-fields.md Normal file
View File

@@ -0,0 +1,474 @@
# Builder Fields & Acai Attributes
## Nombres de variables
El atributo `data-field-label` se convierte a variable removiendo espacios y caracteres especiales (minúsculas).
| Label | Variable |
|-------|----------|
| Categoría Noticia | `categoranoticia` |
| Color Principal | `colorprincipal` |
| Título Producto | `ttuloproducto` |
---
## Field Types (`data-field-type`)
| Type | Element | Returns |
|------|---------|---------|
| `textfield` | `<p>` | String |
| `headfield` | `<h1>`-`<h6>` | String + variable `_tag` con la etiqueta elegida |
| `textbox` | `<div>` | String multi-línea |
| `wysiwyg` | `<div class="wysiwyg">` | HTML string |
| `link` | `<a>` | URL string (ya incluye barras) |
| `upload` | `<img>` | **Array** de `{urlPath, info1, info2, info3, info4}` |
| `uploadMulti` | `<li>` | Itera sobre archivos subidos |
| `list` (fijo) | `<div data-list-options="...">` | Valor seleccionado |
| `list` (tabla) | `<div data-list-table="...">` | `num` del registro |
| `multiv2` | `<li>` wrapper | Array de objetos |
### textfield
```html
<p data-field-type="textfield" data-field-label="Título">
Elemento editable
</p>
```
### headfield
Genera 2 variables: la estándar y otra con sufijo `_tag` con la etiqueta elegida por el usuario.
```html
<{{ title_tag | default('h2') }} data-field-type="headfield" data-field-label="Título Sección" class="text-3xl font-bold">
Título de la sección
</{{ title_tag | default('h2') }}>
```
### textbox
```html
<div data-field-type="textbox" data-field-label="Descripción">
Texto largo editable
</div>
```
### wysiwyg
```html
<div class="wysiwyg" data-field-type="wysiwyg" data-field-label="Contenido Enriquecido">
<p>Texto con <strong>estilos</strong> editables</p>
</div>
```
### link
```html
<a data-field-type="link" data-field-label="Enlace Principal" href="#">
Haz clic aquí
</a>
```
### upload
```html
<div class="p-1/6 relative">
<img
class="absolute top-0 left-0 w-full h-full object-cover object-center lazyload"
data-field-type="upload"
data-field-label="Imagen Principal"
data-lazy="true"
data-field-info1="titulo"
data-field-width="1400"
alt=""
>
</div>
```
Atributos disponibles:
- `data-lazy="true"`: Carga perezosa
- `data-field-width="1400"`: Ancho máximo sugerido
- `data-field-info1="titulo"`: Campo de información adicional (usado como alt)
Acceso en Twig: `{{ imagen[0].urlPath }}`, `{{ imagen[0].info1 }}`
### uploadMulti
Itera sobre todas las imágenes subidas:
```html
<li data-field-type="uploadMulti" data-field-label="Galería" data-field-info1="titulo">
<div class="relative min-h-screen">
<img class="absolute top-0 left-0 w-full h-full object-cover lazyload"
data-src="{{ uploadMulti.urlPath | imagec(2100) }}"
alt="{{ uploadMulti.info1 }}">
</div>
</li>
```
### list (opciones fijas)
```html
<div
data-field-type="list"
data-field-label="Color Producto"
data-list-options="Rojo,Azul,|Verde,3|Amarillo"
>
</div>
```
Formato de opciones: `opcion1,opcion2,|opcion3,valor3|opcion4`
### list (tabla)
```html
<div
data-field-type="list"
data-field-label="Noticia Destacada"
data-list-table="noticias"
data-list-value="num"
data-list-label="titulo"
>
{{ record.titulo }}
</div>
```
- `data-list-table`: Nombre de tabla sin prefijo `cms_`
- `data-list-value`: Campo a usar como valor (generalmente `num`)
- `data-list-label`: Campo a mostrar como label
### multiv2 — Campos repetibles
```html
<ul>
<li data-field-type="multiv2" data-field-label="Productos">
<div data-field-type="textfield" data-field-label="Nombre">
Nombre del producto
</div>
<div data-field-type="textbox" data-field-label="Descripción">
Descripción del producto
</div>
<div class="p-1/6 relative">
<img
class="absolute top-0 left-0 w-full h-full object-cover lazyload"
data-field-type="upload"
data-field-label="Imagen"
data-lazy="true"
data-field-width="800"
alt=""
>
</div>
</li>
</ul>
```
Uso en Twig — las variables son propiedades del objeto iterado:
```twig
{% for record in productos %}
<div class="producto">
<h3>{{ record.nombre }}</h3>
<p>{{ record.descripcion }}</p>
<img src="{{ record.imagen[0].urlPath }}" alt="">
</div>
{% endfor %}
```
---
## Acai Attributes
### `c-if` — Renderizado condicional
```html
<!-- Verificar existencia de variable -->
<div c-if="subtitle">{{ subtitle }}</div>
<!-- Comparación de valores (usa = no ==) -->
<div c-if="layout = 'grid'">Grid layout</div>
```
### `c-else`
Debe ir inmediatamente después del elemento `c-if`:
```html
<div c-if="image">
<img src="{{ image[0].urlPath }}" />
</div>
<div c-else>
<p>No image available</p>
</div>
```
### `c-for` — Iteración sobre array
```html
<div c-for="item in record.features">
<h3>{{ item.title }}</h3>
</div>
```
### `c-for` — Iteración sobre tabla de BD
```html
<ul>
<li c-for="producto in productos" c-where="'visible=1'" c-order="'num desc'" c-limit="10">
{{ producto.title }}
</li>
</ul>
```
Parámetros opcionales: `c-where` (condición SQL), `c-order` (orden), `c-limit` (límite).
Equivalente en Twig:
```twig
{% for producto in 'productos' | get('visible=1','num desc',10) %}
<li>{{ producto.title }}</li>
{% endfor %}
```
Dentro del loop: `loop.index` (1-based), `loop.index is odd`, `loop.index is even`
### `c-class` — Clases CSS condicionales
```html
<!-- Simple -->
<div c-class="{ 'text-center': alineacion == '1', 'text-right': alineacion == '2' }">
<!-- Múltiples condiciones -->
<div c-class="{
'flex-row-reverse': orden == '1',
'cursor-pointer click-a-child': record.enlace_anchor,
'rounded-xl': radioborde == '4'
}">
<!-- Con expresiones Twig (loop) -->
<div c-class="{
'md:order-1': loop.index is odd,
'md:pl-6': loop.index is even
}">
<!-- Combinado con clases estáticas -->
<div class="flex items-center" c-class="{ 'justify-center': centrado }">
```
### `c-hidden` — Elementos ocultos
Elemento que no se renderiza pero puede declarar variables builder:
```html
<div c-hidden="true">
<input data-field-type="textfield" data-field-label="Config" value="default" />
</div>
```
### `c-required` — Campos requeridos condicionales
```html
<input type="text" name="telefono" c-required="'2' not in camposquitar" placeholder="Teléfono">
```
---
## Definiendo variables con `<set>`
```html
<!-- Obtener configuración de la BD -->
<set :tienda="'configuracion_tienda' | get('num != 0')[0]"></set>
<!-- Construir URLs dinámicas -->
<set :logo="tienda.logo.0.urlPath ? 'https://' ~ server.HTTP_HOST ~ tienda.logo.0.urlPath : 'https://' ~ server.HTTP_HOST ~ '/template/estandar/images/logo.png'"></set>
<!-- Twig set para expresiones complejas -->
{% set gracias = 'apartados' | get('num = 20').0 %}
```
---
## Incluyendo módulos
Para incluir un módulo dentro de otro módulo o sección general, usa el ID del módulo como etiqueta HTML:
```html
<module_id :param1="value1" :param2="value2"></module_id>
```
Ejemplo:
```html
<header_menu :showLogo="true" :menuItems="items"></header_menu>
<product_card :product="selectedProduct" :showPrice="true"></product_card>
```
El módulo hijo recibe los parámetros como variables en su contexto.
---
## Formularios (`c-form`)
Manejo automático de validación, almacenamiento en BD y envío de emails.
```html
<c-form
class="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow"
tableName="'solicitudes'"
mailRecord="['correos', 'CONTACTO']"
sendTo="'contacto@empresa.com'"
sendToClient="'email'"
captcha="true"
honeypot="true"
messageOK="'¡Gracias! Te contactaremos pronto'"
messageKO="'Por favor, completa todos los campos'"
redirect="'/gracias'"
attachFiles="true"
>
<div class="mb-4">
<label class="block mb-2">Nombre</label>
<input name="nombre" type="text" class="w-full p-2 border rounded" required>
</div>
<div class="mb-4">
<label class="block mb-2">Email</label>
<input name="email" type="text" class="w-full p-2 border rounded" required>
</div>
<div class="mb-4">
<label class="block mb-2">Mensaje</label>
<textarea name="mensaje" class="w-full p-2 border rounded" rows="5" required></textarea>
</div>
<div class="mb-4">
<label class="flex items-center">
<input name="acepto_politica" type="checkbox" class="mr-2" required>
<span>Acepto la política de privacidad</span>
</label>
</div>
<button type="submit" class="bg-teal-500 text-white px-6 py-2 rounded hover:bg-teal-600">Enviar</button>
<captcha/>
</c-form>
```
### Atributos de c-form
| Atributo | Descripción |
|----------|-------------|
| `tableName="'table'"` | Tabla donde almacenar registros |
| `mailRecord="['correos', 'ID']"` | Template de email de la tabla `correos` |
| `sendTo="'email@domain.com'"` | Destinatarios (separados por coma) |
| `sendToClient="'campo_email'"` | Campo con email del cliente para auto-reply |
| `captcha="true"` | Google reCAPTCHA |
| `honeypot="true"` | Campo oculto anti-spam |
| `messageOK="'texto'"` | Mensaje de éxito |
| `messageKO="'texto'"` | Mensaje de error |
| `redirect="'/path/'"` | Redirección tras envío exitoso |
| `attachFiles="true"` | Adjuntar archivos al email |
| `showImages="true"` | Mostrar thumbnails en email |
| `emailMode="'twig'"` | Email en formato Twig |
| `header="'<div>...</div>'"` | HTML cabecera del email |
| `footer="'<div>...</div>'"` | HTML footer del email |
| `styles="'body { ... }'"` | CSS para el email |
---
## Componentes Built-in
### Carousel (`c-tns-wrapper`)
```html
<div class="c-tns-wrapper"
data-responsive='{"0":1,"768":2,"1024":3}'
data-speed="400"
data-nav="true"
data-autoplay-timeout="3000">
<div c-for="slide in record.slides">
<img src="{{ slide.image[0].urlPath }}" />
</div>
</div>
```
### Lightbox
```html
<a href="{{ image[0].urlPath }}" class="glightbox" data-gallery="gallery1">
<img src="{{ image[0].urlPath | imagec(400) }}" />
</a>
```
### Breadcrumb
```html
<breadCrumb/>
```
### Animate On Scroll (AOS)
```html
<div data-aos="fade-up" data-aos-delay="200">
Animated content
</div>
```
### Lazy Loading
```html
<img class="lazyload" data-src="{{ image[0].urlPath }}" />
<!-- o -->
<img data-lazy="true" src="{{ image[0].urlPath }}" />
```
---
## Puntos importantes
1. **Nombres de variables:** `data-field-label` → sin espacios ni caracteres especiales, minúsculas
2. **Variables en multiv2:** Son propiedades del objeto iterado (`record.nombre`)
3. **Campos upload:** Retornan arrays, no strings (`imagen[0].urlPath`, no `imagen`)
4. **c-if usa `=` no `==`:** `c-if="layout = 'grid'"` (un solo igual)
5. **c-for tabla:** El nombre de tabla va sin prefijo `cms_`
6. **Enlace:** Ya incluye barras, no añadir extras
7. **Checkbox:** Valores `1` o `0`, no `true`/`false`
---
## MCP Tools: Config Vars e Imágenes de Módulos
### Regla importante: Siempre rellenar variables al añadir un módulo
Cuando se añade un módulo a una página (con `add_module_to_record`), este queda vacío y no muestra nada visible. **SIEMPRE** hay que llamar a `set_module_config_vars` inmediatamente después para rellenar las variables con contenido de ejemplo coherente con el contexto del sitio. Incluir:
- Textos (títulos, descripciones, pretítulos) con contenido relevante al sitio
- Valores de listas/selects con una opción válida
- Para variables multi (records), crear al menos 2-3 items de ejemplo
- Para variables de imagen (upload), usar `generate_image` o `upload_record_image` para que el módulo se vea completo
Un módulo sin variables configuradas es invisible en la web.
### Leer variables de un módulo
Antes de modificar cualquier módulo, usar `get_module_config_vars` para conocer el estado actual:
- **tableName**: tabla del registro padre (ej: `apartados`), SIN prefijo `cms_`
- **recordNum**: campo `num` del registro padre (ej: `2`)
- **sectionId**: el `section_id` de la instancia del módulo (ej: `6c6d8`)
### Escribir variables de un módulo
Usar `set_module_config_vars` con los mismos tableName, recordNum y sectionId. Pasar todos los valores como strings.
La respuesta incluye `configVars` con el `recordNum` del registro `builder_custom` creado/actualizado y `uploadFields` para imágenes.
**Tipos de almacenamiento (manejado automáticamente):**
- `headfield`, `textfield`, `link`, `textbox`, `wysiwyg`, `upload` → se guardan en tabla `builder_custom`
- `list`, `checkbox`, `colorpicker` → se guardan directamente en el JSON config-vars (no en builder_custom)
No necesitas preocuparte por esto — `set_module_config_vars` lo maneja internamente. Solo pasa los valores como strings.
### Subir imágenes a un módulo
El nombre del campo de imagen viene de `builder.json``vars.NOMBRE.relations.builder_custom` (ej: `"image1"`). NO es el nombre de la variable (ej: NO `"imagenes"`).
**Flujo correcto:**
1. `get_module_config_vars` → obtener el `recordNum` en builder_custom de la variable de imagen
2. `upload_record_image` con:
- `tableName`: `"builder_custom"` (siempre, sin prefijo cms_)
- `recordId`: el `recordNum` del paso 1 (ej: `"778"`)
- `fieldName`: el campo de relations del builder.json (ej: `"image1"`)
- `imageUrl`: URL completa accesible desde Docker
3. `reorder_record_uploads` si es necesario — pasar array de upload IDs en el orden deseado
4. `list_record_uploads` para verificar
**Errores comunes a evitar:**
- NO usar el sectionId como recordId — usar el `num` de builder_custom
- NO usar el nombre de la variable como fieldName — usar el campo de relations del builder.json (ej: `image1`, no `imagenes`)
- NO poner prefijo `cms_` en tableName

225
docs/css-js-conventions.md Normal file
View File

@@ -0,0 +1,225 @@
# CSS & JavaScript Conventions
## Estructura del módulo
- Genera HTML + CSS + JS (o Vue 3 si es necesario)
- Define una clase raíz en kebab-case: `product-card`, `hero-section`, etc.
- Todo el CSS y JS scopeado bajo esa clase raíz
---
## CSS
### Tailwind First
Usar TailwindCSS como método principal. Solo CSS custom cuando Tailwind no cubra el estilo o se necesiten estados complejos/transiciones específicas.
```html
<div class="flex items-center gap-4 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold text-gray-900">Title</h2>
</div>
```
### BEM para CSS Custom
Cuando se necesite CSS personalizado, siempre scopeado bajo la clase raíz con BEM:
```css
.hero-section { }
.hero-section__title { }
.hero-section__image { }
.hero-section--dark { }
```
Nunca usar clases globales sin prefijo de módulo.
### CSS Variables del tema
```css
var(--main-color) /* Color de marca primario */
var(--main-color-light) /* Variante clara */
var(--main-color-dark) /* Variante oscura */
```
### Estilos inline con fallbacks
Patrón para colores configurables por el usuario:
```html
<div style="background-color: {{ colordefondo ? colordefondo : 'transparent' }}">
<p style="color: {{ colordeltexto ? colordeltexto : '#111827' }}">
```
### Clases utilitarias de Acai
| Clase | Descripción |
|-------|-------------|
| `transition3s` | Transición suave 0.3s |
| `click-a-child` | Hace el padre clickeable via primer `<a>` hijo |
| `line-clamp2` / `line-clamp3` / `line-clamp5` | Truncar texto a N líneas |
| `filter-white` | Filtro CSS para hacer imágenes/iconos blancos |
| `lazyload` | Lazy loading (usar con `data-src`) |
| `text-shadow` | Sombra de texto para legibilidad sobre imágenes |
| `wysiwyg` | Wrapper para contenido de texto enriquecido |
| `bg-main-color` / `bg-main-color-light` / `bg-main-color-dark` | Fondos con color primario |
| `text-main-color` / `text-main-color-light` / `text-main-color-dark` | Texto con color primario |
---
## JavaScript
### Module Scripts (`script.js`)
JavaScript scopeado al módulo usando `section_id`:
```js
const section = document.getElementById('{{ section_id }}');
if (section) {
const buttons = section.querySelectorAll('.btn');
// ...
}
```
### CmsApi (Client-Side)
```js
CmsApi.hook('/hooks/module_id/', { action: 'getData', id: 123 }, function(response) {
console.log(response);
});
```
### Cuándo usar Vue 3
Usar Vue 3 CDN cuando la lógica requiera:
- Doble binding / reactividad
- Solicitudes asíncronas complejas
- Componentes reutilizables
- Gestión de estado local
- Ciclos de vida
Para lógica simple, usar JavaScript vanilla.
### Vue 3 Integration
```html
<div id="app-{{ section_id }}">
<p>${ message }</p>
<button @click="increment">${ count }</button>
</div>
<script>
const { createApp, ref } = Vue;
createApp({
delimiters: ['${', '}'], // Evitar conflicto con Twig {{ }}
setup() {
const message = ref('Hello');
const count = ref(0);
const increment = () => count.value++;
return { message, count, increment };
}
}).mount('#app-{{ section_id }}');
</script>
```
Siempre usar `'${'` y `'}'` como delimitadores Vue para evitar conflicto con Twig.
---
## Variables Globales Disponibles
| Variable | Descripción | Ejemplo |
|----------|-------------|---------|
| `section_id` | ID único por instancia del módulo | `<div id="{{section_id}}">` |
| `server.HTTP_HOST` | Dominio actual | `https://{{ server.HTTP_HOST }}/path` |
| `loop.index` | Índice de iteración (1-based) en c-for/for | `{{ loop.index }}` |
| `loop.index is odd` | True en iteraciones impares | Layouts alternados |
| `loop.index is even` | True en iteraciones pares | Patrones zigzag |
| `interno` | True dentro del editor CMS | `c-class="{'editor-mode': interno}"` |
### Patrón section_id
Cada instancia de módulo recibe un `section_id` único. Usar para navigation anchor e IDs:
```html
<div id="{{section_id}}"></div>
<section id="id_{{ section_id }}" class="relative">
<!-- contenido del módulo -->
</section>
```
---
## Componentes Nativos
### Carousel (`c-tns-wrapper`)
```html
<div class="c-tns-wrapper" data-responsive="sm:2, md:3, lg:4" data-speed="1000" data-nav="true">
<ul class="c-tns-container">
<li data-field-type="multiv2" data-field-label="Slides" class="px-2">
<!-- contenido del slide -->
</li>
</ul>
</div>
```
| Atributo | Descripción | Ejemplo |
|----------|-------------|---------|
| `data-responsive` | Items por breakpoint | `"sm:2, md:3, lg:4"` |
| `data-autoplay-timeout` | Intervalo autoplay (ms) | `"5000"` |
| `data-mode` | Modo de transición | `"gallery"` o `"carousel"` |
| `data-speed` | Velocidad de transición (ms) | `"400"` |
| `data-nav` | Puntos de navegación | `"true"` |
Dots de navegación custom:
```html
<div class="c-tns-nav-container absolute bottom-4 left-0 w-full flex justify-center items-end z-20">
<div c-for="item in records"
class="pointer-events-auto cursor-pointer rounded-full border-2 border-white w-4 h-4 mx-1 bg-black bg-opacity-50">
</div>
</div>
```
### Lightbox
```html
<a href="{{ image[0].urlPath }}" class="glightbox" data-gallery="gallery1">
<img src="{{ image[0].urlPath | imagec(400) }}" />
</a>
```
### Breadcrumb
```html
<breadCrumb class="bg-gray-200 p-3 rounded" c-prevlinks="null"></breadCrumb>
```
### AOS (Animate On Scroll)
```html
<div data-aos="fade-up" data-aos-duration="800">Contenido</div>
```
Valores comunes: `fade-up`, `fade-down`, `fade-left`, `fade-right`, `zoom-in`, `zoom-in-up`, `fade-up-right`, `fade-up-left`
Después de cambios dinámicos: `AOS.refresh()` en JavaScript.
### Lazy Loading
```html
<!-- Builder var con lazy loading -->
<img data-field-type="upload" data-field-label="Imagen" data-lazy="true" data-field-width="800" alt="">
<!-- Manual en templates -->
<img class="lazyload" data-src="{{ record.imagen[0].urlPath | imagec(800) }}" alt="">
```
---
## Buenas prácticas
- HTML/Twig semántico
- Código limpio y organizado
- Evitar dependencias externas innecesarias
- Evitar estilos inline salvo casos justificados (colores dinámicos del usuario)
- No usar clases globales sin prefijo de módulo

415
docs/hooks-and-api.md Normal file
View File

@@ -0,0 +1,415 @@
# Hooks & Server-Side API
## Hooks
Hooks son archivos PHP en `hooks/` que ejecutan lógica server-side. También pueden estar dentro de un módulo en `template/estandar/modulos/<module-id>/hook.php`.
### Estructura de un Hook
```php
<?php
// Los parámetros se reciben como variables directamente
// Ejemplo: Si llamas hook con {param1: 100}, tendrás $param1 = 100
$resultado = $param1 * 2;
// Retornar un array (se convierte a JSON)
return [
"success" => true,
"message" => "Valor procesado: " . $resultado,
"value" => $resultado
];
?>
```
### Testing Hooks
El Docker debe estar corriendo. Hacer curl al endpoint del hook:
```bash
curl http://localhost:8080/hooks/example_hook/
```
No usar X-Hooks-Token en desarrollo local.
### Cómo Llamar Hooks
**Desde HTML (recomendado para módulos):**
```html
<hook result="myVar" endpoint="/hooks/module_id/" :param1="value1" :param2="'string'"></hook>
<p>{{ myVar.message }}</p>
```
**Desde Twig:**
```twig
{% set resultado = 'hooks/mimodulo/' | hook({param1: 100, param2: 'texto'}) %}
<p>{{ resultado.message }}</p>
```
**Desde JavaScript:**
```js
CmsApi.hook('/hooks/mimodulo/', {param1: 100, param2: 'texto'}, (data) => {
console.log(data.message);
});
```
**Desde otro Hook PHP:**
```php
<?php
$result = hook("/hooks/mimodulo/", ["param1" => 100, "param2" => "texto"]);
$mensaje = $result["message"];
?>
```
**Desde c-form:** Los hooks se ejecutan automáticamente al enviar el formulario si están configurados.
---
## CmsApi (PHP)
API server-side para operaciones de base de datos. Disponible en todos los hooks.
### Read — `CmsApi::get()`
```php
// Todos los registros
$products = CmsApi::get('productos');
// Con condición WHERE
$active = CmsApi::get('productos', ['active' => 1]);
// Con orden y límite
$latest = CmsApi::get('noticias', [], 'fecha DESC', 5);
// Con condición string
$activos = CmsApi::get('productos', 'activo=1');
// Condición compleja como array
$caros = CmsApi::get('productos', [
["column" => "precio", "operator" => ">", "value" => 100]
]);
// Múltiples condiciones (AND)
$resultados = CmsApi::get('productos', [
["column" => "activo", "operator" => "=", "value" => 1],
["column" => "stock", "operator" => ">", "value" => 0]
]);
// Con operadores
$expensive = CmsApi::get('productos', ['precio' => ['>=' => 100]]);
$search = CmsApi::get('productos', ['nombre' => ['LIKE' => '%keyword%']]);
$inList = CmsApi::get('productos', ['categoria_num' => ['IN' => [1, 2, 3]]]);
// Con opciones
$datos = CmsApi::get('productos', '', '', '', [
'translates' => true,
'uploads' => true,
'relations' => true,
'relationsDepth' => 2
]);
```
### Insert — `CmsApi::insert()`
```php
// Un registro
CmsApi::insert('contacto', [
["nombre" => "John", "email" => "john@example.com", "mensaje" => "Hello"]
]);
// Múltiples registros
CmsApi::insert('productos', [
["nombre" => "Producto A", "precio" => 100],
["nombre" => "Producto B", "precio" => 200]
]);
// Con retorno del último ID
CmsApi::insert('productos',
[["nombre" => "Nuevo", "precio" => 150]],
[],
['return_last_id' => true]
);
```
### Update — `CmsApi::update()`
```php
// Con condición string
CmsApi::update('productos', ["precio" => 150], "num=1");
// Con condición array
CmsApi::update('productos',
["activo" => 1],
[["column" => "num", "operator" => "=", "value" => 1]]
);
// Múltiples registros
CmsApi::update('productos', ["activo" => 0], "precio < 50");
```
### Delete — `CmsApi::delete()`
```php
CmsApi::delete('productos', "num=5");
CmsApi::delete('productos',
[["column" => "activo", "operator" => "=", "value" => 0]]
);
```
### Reglas importantes
- Nombres de tabla **sin** prefijo `cms_`
- Primary key siempre es `num`, nunca `id`
- Foreign keys: `categoria_num`, no `categoria_id`
- Upload fields: no se manejan via insert/update
- Operadores: `=`, `!=`, `>`, `>=`, `<`, `<=`, `LIKE`, `IN`
---
## CmsApi (JavaScript — Client-Side)
```js
// Llamar hook
CmsApi.hook('/hooks/module_id/', { param: 'value' }, function(response) {
// response es la salida del hook
});
// Leer registros (si está expuesto via hooks)
CmsApi.get('tableName', { where: conditions }, function(records) {
// records array
});
```
---
## CocoDB
Capa de abstracción de BD de bajo nivel usada internamente por CmsApi. Usar directamente desde hooks cuando necesites más control.
### `CocoDB::get($table, $where, $order, $limit, $options)`
```php
// Básico
$records = CocoDB::get('productos', ['activo' => 1], 'orden ASC', 10);
// Where con operadores avanzados
$records = CocoDB::get('productos', [
['column' => 'precio', 'value' => 100, 'operator' => '>='],
['column' => 'categoria_num', 'value' => [1, 2, 3], 'operator' => 'IN'],
]);
// Condiciones OR
$records = CocoDB::get('productos', [
['column' => 'nombre', 'value' => '%keyword%', 'operator' => 'LIKE'],
['column' => 'descripcion', 'value' => '%keyword%', 'operator' => 'LIKE', 'or' => true],
]);
// NOT
$records = CocoDB::get('productos', [
['column' => 'estado', 'value' => 'borrador', 'operator' => '=', 'not' => true],
]);
// IS NULL
$records = CocoDB::get('productos', [
['column' => 'fecha_baja', 'value' => '', 'operator' => 'IS NULL'],
]);
// Limit con offset
$records = CocoDB::get('productos', [], 'num DESC', ['limit' => 10, 'offset' => 20]);
```
#### Opciones de `get()`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `uploads` | bool | `true` | Incluir datos de upload fields |
| `relations` | bool/array | `true` | Resolver foreign keys. Array para limitar: `['category']` |
| `relationsDepth` | int | 2 | Profundidad de relaciones anidadas |
| `translates` | string | current lang | Código de idioma |
| `groupBy` | string | null | GROUP BY clause |
| `aggregates` | array | `[]` | Funciones de agregación |
| `onlyFields` | array | null | Seleccionar solo campos específicos |
| `debug` | bool | false | Mostrar SQL query |
| `redis` | bool | null | Forzar cache Redis |
| `redis_expire` | int | 60 | TTL de cache Redis (segundos) |
### `CocoDB::insertRecords($table, $records, $functions, $options)`
```php
// Un registro
$count = CocoDB::insertRecords('contacto', [
'nombre' => 'John',
'email' => 'john@example.com',
]);
// Usar mysql_insert_id() para obtener el nuevo num
// Múltiples
$count = CocoDB::insertRecords('productos', [
['nombre' => 'Product A', 'precio' => 10],
['nombre' => 'Product B', 'precio' => 20],
]);
```
#### Opciones de insert/update
| Option | Description |
|--------|-------------|
| `forceNum` | Permite setear el campo `num` manualmente |
| `ignoreSchema` | Saltar validación de schema |
| `ignoreFields` | Array de campos a ignorar |
### `CocoDB::updateRecords($table, $records, $where, $functions, $options)`
```php
CocoDB::updateRecords('productos',
['precio' => 29.99, 'activo' => 1],
['num' => 42]
);
// Con operador en where
CocoDB::updateRecords('productos',
['activo' => 0],
[['column' => 'stock', 'value' => 0, 'operator' => '<=']]
);
```
### `CocoDB::deleteRecords($table, $where, $options)`
```php
CocoDB::deleteRecords('productos', ['num' => 42]);
CocoDB::deleteRecords('logs', [
['column' => 'fecha', 'value' => '2024-01-01', 'operator' => '<']
]);
```
### Parámetro `$functions`
Permite aplicar funciones MySQL a valores durante insert/update:
```php
CocoDB::insertRecords('logs', [
'mensaje' => 'Login exitoso',
'fecha' => '',
], [
'fecha' => 'NOW()',
]);
```
### Where Clause — Formatos
**Simple (key-value):**
```php
['campo' => 'valor'] // campo = 'valor'
```
**Avanzado (array de condiciones):**
```php
[
'column' => 'field_name',
'value' => 'match_value',
'operator' => '=', // =, !=, <, >, <=, >=, LIKE, IN, IS NULL
'or' => false, // OR en vez de AND
'not' => false, // Negar la condición
'raw_key' => false, // Saltar check de existencia de columna
]
```
---
## Creación y Actualización de Registros
### Flujo correcto
1. Consultar el esquema de la tabla (leer `cms/data/schema/{tabla}.ini.php`)
2. Revisar los tipos de campo
3. Rellenar según el tipo de dato
4. Enviar con la estructura correcta
### Tipos de campo y formato
| Tipo | Formato | Ejemplo |
|------|---------|---------|
| **Text field** | String | `"Texto"` |
| **Text box** | String multilínea | `"Línea 1\nLínea 2"` |
| **Date/time** | `YYYY-MM-DD HH:mm:ss` | `"2025-12-03 10:30:00"` |
| **Wysiwyg** | String HTML | `"<p class=\"font-bold\">Texto</p>"` |
| **List** | String o número | `"activo"` o `"1"` (num si es foreign key) |
| **Checkbox** | Número 1/0 | `1` o `0` |
| **Multivalores** | String JSON | `"[{\"producto\":\"1\"}]"` |
| **Upload** | **NO enviar** — usar `upload_record_image` después de crear el registro |
---
## Table Schemas
Los schemas están en `cms/data/schema/` como archivos `.ini.php`. Definen:
- Nombres y tipos de campo
- Reglas de validación
- Relaciones (foreign keys)
- Configuración de display
---
## Ejemplos Prácticos
### Hook de Cálculo de Precio
```php
<?php
// hook.php del módulo "calcular_precio"
$precioUnitario = 50;
if ($tipo === 'mayoreo' && $cantidad > 10) {
$precioUnitario *= 0.85; // 15% descuento
}
return [
"success" => true,
"precioUnitario" => round($precioUnitario, 2),
"total" => round($precioUnitario * $cantidad, 2),
"descuento" => $tipo === 'mayoreo' ? 15 : 0
];
?>
```
```html
<hook result="precio" endpoint="/hooks/calcular_precio/" :cantidad="10" :tipo="'mayoreo'"></hook>
<p>Total: ${{ precio.total }}</p>
```
### Hook con Operaciones de BD
```php
<?php
// hook.php del módulo "procesar_compra"
$producto = CmsApi::get('productos', "num=$producto_id");
if (empty($producto)) {
return ["success" => false, "message" => "Producto no encontrado"];
}
$total = $producto[0]['precio'] * $cantidad;
// Crear venta
CmsApi::insert('ventas', [[
"usuario_num" => $usuario_id,
"producto_num" => $producto_id,
"cantidad" => $cantidad,
"total" => $total,
"fecha" => date('Y-m-d H:i:s')
]], [], ['return_last_id' => true]);
// Actualizar stock
$stock = CmsApi::get('stocks', "producto_num=$producto_id");
if (!empty($stock)) {
CmsApi::update('stocks',
["cantidad" => $stock[0]['cantidad'] - $cantidad],
"producto_num=$producto_id"
);
}
return ["success" => true, "total" => $total];
?>
```

100
docs/mcp-tools-reference.md Normal file
View File

@@ -0,0 +1,100 @@
# MCP Tools Reference
## Quick Reference
| Tool | Categoría | Acción |
|------|-----------|--------|
| `create_module` | Módulos | Crea módulo nuevo (directorio + archivos + compila) |
| `compile_module` | Módulos | Compila módulo tras editar index-base.tpl |
| `check_module` | Módulos | Preview de cómo renderiza un módulo |
| `check_module_usage` | Módulos | Qué páginas usan un módulo |
| `set_module_example_data` | Módulos | Datos de ejemplo para editor visual |
| `list_page_modules` | Registros | Lista módulos de una página |
| `add_module_to_record` | Registros | Añade módulo a una página |
| `remove_module_from_record` | Registros | Elimina módulo de una página |
| `reorder_module` | Registros | Cambia posición de un módulo |
| `toggle_module_visibility` | Registros | Muestra/oculta módulo |
| `get_module_config_vars` | Registros | Lee variables de un módulo |
| `set_module_config_vars` | Registros | Escribe variables de un módulo |
| `list_table_records` | Registros | Buscar/listar registros con filtros |
| `get_record` | Registros | Obtener un registro por num |
| `create_or_update_record` | Registros | Crear o actualizar registros |
| `delete_table_records` | Registros | Eliminar registros (destructivo) |
| `upload_record_image` | Media | Subir imagen a campo de registro (desde URL) |
| `generate_image` | Media | Generar imagen con IA y guardar en uploads |
| `upload_image_to_assets` | Media | Subir imagen a /images/ del template |
| `list_record_uploads` | Media | Listar uploads de un campo |
| `replace_record_image` | Media | Reemplazar imagen existente |
| `delete_record_upload` | Media | Borrar upload |
| `reorder_record_uploads` | Media | Reordenar imágenes de un campo |
| `refresh_acai_token` | Auth | Renovar token JWT expirado |
| `navigate_browser` | Navegación | Navegar el browser del frontend a una URL |
| `save_project_styles` | Proyecto | Guardar resumen de estilos en docs/project-styles.md |
| `orchestrate_task` | Orquestador | Guía paso a paso para tareas complejas |
| `rollback_git` | Git | Recuperar cambios de git remoto |
## Flujos de trabajo
### Crear un módulo nuevo desde cero
1. `create_module` — Crea el directorio con index-base.tpl, style.css, script.js y compila
2. `add_module_to_record` — Añade el módulo a una página (tabla padre, ej: `apartados`)
3. `set_module_config_vars` — Rellena las variables con contenido (textos, colores, opciones). **OBLIGATORIO** — sin esto el módulo no muestra nada. Devuelve:
- `configVars`: mapa de variables → recordNums
- `uploadFields`: mapa de variables upload → `{ fieldName, recordNum }`**usa estos directamente** para subir imágenes sin necesidad de leer builder.json
- Para vars multi con uploads: `uploadFields["varName.subVarName"]` es un array con `[{ index, fieldName, recordNum }]`
4. Para imágenes: `generate_image` o `upload_record_image` usando el `recordNum` y `fieldName` del `uploadFields` devuelto en el paso 3
5. Verificar con `check_module` o recargando la página
### Editar un módulo existente
1. `get_module_config_vars` — Leer el estado actual del módulo (variables, recordNums)
2. Editar `index-base.tpl` con la tool `Write` o `Edit`
3. `compile_module`**OBLIGATORIO** tras cada edición de index-base.tpl
4. Si cambias variables: `set_module_config_vars` para actualizar valores
### Añadir/modificar imágenes de un módulo
**Tras `set_module_config_vars`** (método recomendado — sin pasos extra):
1. El response de `set_module_config_vars` incluye `uploadFields` con los `recordNum` y `fieldName` de cada variable upload
2. `upload_record_image` con `tableName: "builder_custom"`, `recordId` y `fieldName` del `uploadFields`
3. Para uploads dentro de vars multi: `uploadFields["records.imagen"]` devuelve array con `{ index, fieldName, recordNum }` por cada record
**Sin haber llamado a `set_module_config_vars`**:
1. `get_module_config_vars` — Obtener el `recordNum` de builder_custom
2. Leer `builder.json` del módulo para encontrar el `fieldName` real (ej: `image1`, NO el nombre de la variable)
3. `upload_record_image` con:
- `tableName`: `"builder_custom"` (siempre sin cms_)
- `recordId`: el recordNum del paso 1
- `fieldName`: el campo de relations del builder.json (ej: `image1`)
- `imageUrl`: URL accesible desde Docker (ej: `http://localhost/cms/uploads/...`)
### Generar imagen con IA
1. `generate_image` con prompt descriptivo + style (photographic, digital-art, minimalist...)
2. La imagen se guarda en `cms/uploads/generated/` y devuelve `dockerUrl`
3. Usar esa `dockerUrl` con `upload_record_image` para asignarla a un módulo
### Gestionar registros de una tabla
1. `list_table_records` — Buscar registros con filtros (`where`, `order`, `limit`)
2. `get_record` — Obtener un registro completo por num
3. `create_or_update_record` — Crear o actualizar (la tabla sin prefijo `cms_`, PK es `num`)
4. `delete_table_records` — Eliminar por IDs
### Explorar el sitio
1. `orchestrate_task` con workflow `explore_site` — Guía para entender la estructura
2. `list_page_modules` — Ver qué módulos tiene cada página
3. `get_module_config_vars` — Ver los datos de cada módulo
4. `check_module` — Preview de cómo renderiza
## Reglas importantes para todas las tools
1. **tableName** siempre SIN prefijo `cms_` (ej: `apartados`, no `cms_apartados`)
2. **Primary key** es siempre `num`, nunca `id`
3. **Uploads** son arrays — acceder con `[0].urlPath`
4. **fieldName de imágenes** viene de `builder.json``vars.NOMBRE.relations.builder_custom` (ej: `image1`), NO del nombre de la variable
5. **recordId para imágenes** es el `num` de `builder_custom`, NO el sectionId del módulo
6. Tras `set_module_config_vars`, TODAS las variables del módulo (incluyendo upload) reciben config-vars automáticamente
7. Si el token expira (error 403), usar `refresh_acai_token`

105
docs/modular-system.md Normal file
View File

@@ -0,0 +1,105 @@
# Acai Modular System
## Modules
Modules are the visual building blocks of Acai websites. Each module lives in `template/estandar/modulos/<module-id>/`.
### File Structure
```
<module-id>/
├── index-base.tpl # Source template (EDIT THIS)
├── index.tpl # Compiled output (auto-generated, do NOT edit)
├── index-twig.tpl # Compiled Twig output (auto-generated, do NOT edit)
├── builder.json # Compiled builder vars (auto-generated, do NOT edit)
├── style.css # Module-scoped styles
└── script.js # Module JavaScript
```
### Template Syntax
Templates use a hybrid of **Twig** and **Acai attributes**. The source file is always `index-base.tpl`.
```html
<section class="hero-section" id="{{ section_id }}">
<div class="container mx-auto px-4">
<h2 data-field-type="headfield" class="text-3xl font-bold">
Title here
</h2>
<p data-field-type="textbox" class="text-lg text-gray-600">
Description text
</p>
<img data-field-type="upload" src="placeholder.jpg" class="w-full rounded-lg" />
<a data-field-type="link" href="#" class="btn">Call to action</a>
</div>
</section>
```
### Including Modules from Other Modules
```html
<module_id :param1="value1" :param2="'string value'"></module_id>
```
Parameters are received as variables inside the included module.
### Global Variables
| Variable | Description |
|----------|-------------|
| `section_id` | Unique ID per module instance (use for anchors, JS scoping) |
| `interno` | `true` when viewing in CMS editor, `false` on public site |
| `server.HTTP_HOST` | Current domain |
| `loop.index` | 1-based iteration index (inside `c-for`) |
| `loop.index is odd` / `loop.index is even` | For alternating layouts |
## General Sections
General sections are database-backed templates used for record views, headers, footers, and reusable layouts. They use the same template engine as modules.
### Key Differences from Modules
- Access record data via the `thisrecord` variable
- Upload fields return **arrays**: `thisrecord.image[0].urlPath`
- Additional upload metadata: `info1` (alt text), `info2`, `info3`, `info4`
- Foreign key fields use `_num` suffix: `thisrecord.category_num`
- Saved via `save_general_section()` (not `save_module()`)
- Parser type 2 = Twig (recommended), 0 = Acai legacy syntax
### Example: Record Template
```html
<article class="product-card">
<img src="{{ thisrecord.imagen[0].urlPath }}"
alt="{{ thisrecord.imagen[0].info1 }}"
class="w-full h-64 object-cover" />
<h3 class="text-xl font-semibold">{{ thisrecord.nombre }}</h3>
<p class="text-gray-600">{{ thisrecord.descripcion | raw }}</p>
<span class="text-2xl font-bold">{{ thisrecord.precio }}€</span>
</article>
```
### Variable Assignment
Use `<set>` tag to create variables from queries:
```html
<set :categories="'categorias' | get()"></set>
<set :featured="'productos' | get({destacado: 1}, 'orden ASC', 3)"></set>
```
## Repeatable Content (multiv2)
The `multiv2` builder field type creates repeatable groups of fields:
```html
<div c-for="item in record.items">
<h3 data-field-type="textfield">{{ item.title }}</h3>
<p data-field-type="textbox">{{ item.description }}</p>
<img data-field-type="upload" src="{{ item.image }}" />
</div>
```
Access individual items: `record.items[0].title`, `record.items[1].image`, etc.

View File

@@ -0,0 +1,77 @@
# Module Creation Guide
## Style Reference
When creating new modules, you MUST match the visual style of the existing project. Follow these steps IN ORDER:
### Step 1: Check for `docs/project-styles.md`
- If the file exists → read it and use it as your style reference. DONE — skip to module creation.
- If the file does NOT exist → continue to Step 2.
### Step 2: Determine if exploration is needed
- Count modules in `template/estandar/modulos/` that have `builder.json` and do NOT start with `custom-`
- If 3+ qualifying modules exist → continue to Step 3
- If fewer than 3 → skip exploration, create the module based on the user's description. The style will be defined as modules are created.
### Step 3: Explore and GENERATE the style guide (MANDATORY)
- Read `index-base.tpl` and `style.css` of 3-4 representative modules (only those with `builder.json`, skip `custom-*`)
- **You MUST then call `save_project_styles`** with a markdown summary including:
- Primary/secondary/accent colors (hex values)
- Font families and sizes used
- Spacing scale (padding/margin patterns)
- Common Tailwind classes and custom CSS patterns
- Button styles, card styles, section layouts
- Any recurring design patterns (gradients, shadows, borders, etc.)
- This saves `docs/project-styles.md` which will be read by future module creation tasks — no re-exploration needed.
**After creating a module:** if `docs/project-styles.md` does not exist yet and there are now 3+ modules, call `save_project_styles`.
## Module Structure
Each module lives in `template/estandar/modulos/<moduleId>/` with:
- `index-base.tpl` — Twig template (source — EDIT THIS)
- `style.css` — Module styles
- `script.js` — Module JavaScript
- `builder.json` — Compiled builder vars (auto-generated, do NOT edit)
- `index.tpl` / `index-twig.tpl` — Compiled (auto-generated, do NOT edit)
## Creating a Module — Full Workflow
1. **Read style reference** (steps above)
2. **`create_module`** — Creates the directory with index-base.tpl, style.css, script.js and compiles. Use descriptive `moduleId` and clear `label`.
3. **`add_module_to_record`** — Adds the module to a page. Response includes `sectionId` — use it directly in the next step.
4. **`set_module_config_vars`** — Fill variables with content. Response includes `uploadFields` with `{ fieldName, recordNum }` for each upload variable.
5. **Upload images** — Use `generate_image` then `upload_record_image` with the `recordNum` and `fieldName` from step 4's `uploadFields`. No need to read builder.json or call get_module_config_vars.
6. **`navigate_browser`** — Navigate to the page so the user can see the result.
## HTML Field Types
Use these `data-field-type` attributes in `index-base.tpl`:
| Attribute | Purpose | Example |
|-----------|---------|---------|
| `headfield` | Editable heading | `<h2 data-field-type="headfield">Title</h2>` |
| `textfield` | Short editable text | `<span data-field-type="textfield">Text</span>` |
| `wysiwyg` | Rich text editor | `<div data-field-type="wysiwyg">Content</div>` |
| `upload` | Image upload | `<img data-field-type="upload" src="...">` |
| `list` | Select dropdown | `<div data-field-type="list" data-options="opt1,opt2">` |
| `multiv2` | Repeater/records | `<div data-field-type="multiv2">...</div>` |
| `checkbox` | Toggle | `<div data-field-type="checkbox">` |
| `colorpicker` | Color picker | `<div data-field-type="colorpicker">` |
## MJML Modules
Modules with `MJMLModule: true` in their schema are email modules:
- Only appear when the page table is `mail_marketing`
- For `mail_marketing` tables, only MJML modules are shown
- Use MJML markup instead of standard HTML
## Key Rules
- Always use Tailwind CSS as primary styling
- Use `section_id` variable for unique anchors/scoping
- Use `interno` variable to detect CMS editor vs public view
- Include other modules with: `<module_id :param1="value1"></module_id>`
- After editing `index-base.tpl`, ALWAYS call `compile_module`
- Twig uses filters (with `|`), never functions
- Twig concatenation uses `~`: `'value=' ~ variable`

104
docs/pages-and-records.md Normal file
View File

@@ -0,0 +1,104 @@
# Pages & Records Guide
## Page Types
Every CMS record that has an `enlace` (URL) field is a **page**. Pages come in two types determined by the `controlador` field:
### Builder (Modular) Pages
- `controlador` = `cms/lib/plugins/builder_saas/controlador.php`
- Content is built from **modules** (drag & drop components)
- The `builder` field contains a JSON array of module instances
- Use MCP tools: `add_module_to_record`, `set_module_config_vars`, etc.
- The page template renders modules in order from the builder JSON
### Standard Pages
- `controlador` = `cms/lib/plugins/builder_saas/controlador_tabla.php`
- Content lives directly in the record fields (`content`, `titulo_alternativo`, etc.)
- The `content` field is HTML (wysiwyg)
- Use `create_or_update_record` to edit content directly
- No modules involved
### How to determine page type
**Always check the `controlador` field** of the record:
- Contains `controlador.php` (without `_tabla`) → **Builder**
- Contains `controlador_tabla.php`**Standard**
## Table Types (Sections)
Tables with pages are called **sections**. There are two section types defined by `menuType` in the schema:
### Category (`menuType = "category"`)
- **Hierarchical** — pages have parent/child relationships
- Fields: `parentNum`, `depth`, `globalOrder`, `lineage`, `siblingOrder`
- Example: `apartados` (main site pages)
- Uses `visible_en_el_menu` field for menu visibility
- Ordered by `globalOrder`
### Multi (`menuType = "multi"`)
- **Flat list** — no hierarchy
- Uses `dragSortOrder` for ordering
- Example: `blog`, `travesias`
- Typically uses `visible` field (not `visible_en_el_menu`)
## Critical Rules for Pages
### NEVER change the `enlace` field
Unless the user explicitly asks to change a page URL, **never modify the `enlace` field**. Changing it breaks existing links, SEO, and navigation. The enlace is set when the page is created and should remain stable.
### NEVER change the `controlador` field
The controlador defines whether the page is Builder or Standard. Changing it breaks the page rendering. Only set it during page creation.
### Visibility fields
- `apartados` and other category tables use: `visible_en_el_menu` (1 = visible, 0 = hidden)
- `blog`, `travesias` and other multi tables use: `visible` (1 = visible, 0 = hidden)
- Always check which field the table has before toggling visibility
### Name/Title fields
- Some tables use `name` (e.g. `apartados`)
- Others use `title` (e.g. `blog`, `travesias`)
- Check the schema to know which one to use
## Working with Builder Pages
### Adding content to a new Builder page
1. List available modules: `list_available_modules`
2. Add modules: `add_module_to_record` (one at a time, in order)
3. Configure each module: `set_module_config_vars` with content
4. Add images if needed: `upload_record_image` or `generate_image`
### Editing an existing Builder page
1. List current modules: `list_page_modules`
2. Get module vars: `get_module_config_vars`
3. Update vars: `set_module_config_vars`
4. Or edit the module template: edit `index-base.tpl``compile_module`
## Working with Standard Pages
### Adding content to a Standard page
Use `create_or_update_record` to set:
- `content` — HTML content (main body)
- `titulo_alternativo` — alternative title shown on the page
- `titulo_de_pagina` — browser tab title (SEO)
- `metatag_descripcion` — meta description (SEO)
### Example: Update a standard page
```
create_or_update_record with:
tableName: "apartados"
recordNum: "87"
fields:
content: "<h2>Our Services</h2><p>We offer...</p>"
titulo_de_pagina: "Services | My Site"
metatag_descripcion: "Discover our services..."
```
## The `apartados` Table (Special)
The `apartados` table is the main pages table in most Acai sites. Key characteristics:
- `menuType = "category"` — hierarchical with parent/child
- `parentNum` — the num of the parent page (0 = root level)
- `depth` — nesting level (0 = root, 1 = child, 2 = grandchild)
- `globalOrder` — display order across the entire tree
- `visible_en_el_menu` — whether the page shows in the navigation menu
- `breadcrumb` — auto-generated breadcrumb path
- Pages can be either Builder or Standard (check `controlador` field per record)

262
docs/production-patterns.md Normal file
View File

@@ -0,0 +1,262 @@
# Patrones de Producción
Patrones reales usados en módulos y secciones generales de producción. Usar como referencia al crear nuevos módulos.
---
## Patrón 1: Cabecera de Sección (Pretítulo + Título + Subtítulo)
Bloque de cabecera con colores y alineación configurables. Casi todos los módulos lo usan:
```html
<div c-hidden="true">
<div data-field-type="textfield" data-field-label="Color de fondo"></div>
<div data-field-type="textfield" data-field-label="Color del pretitulo"></div>
<div data-field-type="textfield" data-field-label="Color del titulo"></div>
<div data-field-type="list" data-field-label="Color titulo resaltado"
data-list-options="|Main color,1|Main color light,2|Main color dark,3|Blanco"></div>
<div data-field-type="textfield" data-field-label="Color del subtitulo"></div>
</div>
<div id="{{section_id}}"></div>
<section id="id_{{ section_id }}" class="relative"
style="background-color: {{ colordefondo ? colordefondo : 'transparent' }}">
<div class="container mx-auto max-w-7xl px-6 2xl:px-0 py-10 lg:py-20">
<div c-if="pretitulo or titulo or subtitulo" class="mb-10 lg:mb-16">
<div c-if="pretitulo" data-field-type="textfield" data-field-label="Pretitulo"
class="w-fit mx-auto text-xl md:text-2xl lg:text-3xl text-center px-4 py-2 mb-2"
style="color: {{ colordelpretitulo ? colordelpretitulo : '#111827' }}"
data-aos="fade-down" data-aos-duration="500"></div>
<div c-class="{
'titulo-main-color': colortituloresaltado is empty,
'titulo-main-color-light': colortituloresaltado == '1',
'titulo-main-color-dark': colortituloresaltado == '2',
'titulo-white': colortituloresaltado == '3'
}" style="color: {{ colordeltitulo ? colordeltitulo : '#111827' }}"
data-aos="zoom-in" data-aos-duration="500">
{% if titulo %}
<div data-field-type="headfield" data-field-label="Titulo"
class="titulo-kd text-3xl md:text-4xl lg:text-5xl font-semibold text-center"></div>
{% endif %}
</div>
<div c-if="subtitulo" data-field-type="textfield" data-field-label="Subtitulo"
class="text-xl md:text-2xl lg:text-3xl text-center mt-2"
style="color: {{ colordelsubtitulo ? colordelsubtitulo : '#111827' }}"
data-aos="fade-up" data-aos-duration="500"></div>
</div>
<!-- Contenido del módulo aquí -->
</div>
</section>
```
---
## Patrón 2: Layout Zigzag/Ajedrez (Imagen + Texto alternado)
Usa `loop.index is odd/even` para alternar:
```html
<li c-for="record in records" class="w-full py-6">
<div class="flex flex-wrap items-center"
c-class="{ 'flex-row-reverse': loop.index is even }">
<!-- Lado imagen -->
<div class="w-full md:w-2/5">
<img src="{{ record.imagen[0].urlPath | imagec(800) }}" alt=""
class="w-full h-full object-cover rounded-xl">
</div>
<!-- Lado texto -->
<div class="w-full md:w-3/5 px-6">
<h3 class="text-2xl font-semibold">{{ record.titulobloque | raw }}</h3>
<div class="wysiwyg mt-4">{{ record.textobloque | raw }}</div>
<a c-if="record.enlacebloque_anchor" href="{{ record.enlacebloque }}"
class="inline-block bg-main-color text-white rounded-xl px-6 py-3 mt-6 transition3s">
{{ record.enlacebloque_anchor }}
</a>
</div>
</div>
</li>
```
---
## Patrón 3: Acordeón FAQ
```html
<li data-field-type="multiv2" data-field-label="Records" data-aos="fade-up" data-aos-duration="800">
<div c-if="record.pregunta" class="border-t border-black">
<div class="flex py-6">
<div class="text-main-color-dark text-2xl font-bold mr-4">{{ loop.index }}.</div>
<div class="faq-wrapper w-full">
<div class="faq-page select-none flex justify-between items-center cursor-pointer text-2xl font-medium">
<div data-field-type="textfield" data-field-label="Pregunta" class="flex-1 pr-[10%]"></div>
<span class="faq-icon w-12 h-12 ml-4"></span>
</div>
<div class="faq-body hidden py-4" data-field-type="wysiwyg" data-field-label="Respuesta"></div>
</div>
</div>
</div>
</li>
```
JavaScript para toggle:
```javascript
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".faq-page").forEach(faq => {
faq.addEventListener("click", function () {
const body = faq.nextElementSibling;
const isActive = faq.classList.toggle("active");
body.classList.toggle("hidden", !isActive);
AOS.refresh();
});
});
});
```
---
## Patrón 4: Formulario de Contacto Completo
```html
<set :tienda="'configuracion_tienda' | get('num != 0')[0]"></set>
<set :logo="tienda.logo.0.urlPath ? 'https://' ~ server.HTTP_HOST ~ tienda.logo.0.urlPath : 'https://' ~ server.HTTP_HOST ~ '/template/estandar/images/logo.png'"></set>
{% set imagen = '<img src="' ~ logo ~ '" style="max-height:150px; display: block; margin: 0 auto;">' %}
{% set gracias = 'apartados' | get('num = 20').0 %}
<c-form method="post"
mailRecord="['correos', 'CONTACTO']"
honeypot="true"
header="imagen"
sendToClient="'email'"
tableName="'solicitudes'"
redirect="gracias.enlace"
class="text-black lg:text-lg max-w-2xl mx-auto">
<input type="text" name="nombre" required
placeholder="{{ 'Nombre' | translate }}"
class="w-full bg-white border border-neutral-400 rounded-xl px-6 py-2 my-1" />
<input type="email" name="email" required
placeholder="{{ 'Email' | translate }}"
class="w-full bg-white border border-neutral-400 rounded-xl px-6 py-2 my-1" />
<input type="text" name="telefono" required
placeholder="{{ 'Teléfono' | translate }}"
class="w-full bg-white border border-neutral-400 rounded-xl px-6 py-2 my-1" />
<textarea name="comentario" cols="30" rows="5"
placeholder="{{ 'Escribe aquí tu comentario' | translate }}..."
class="w-full bg-white border border-neutral-400 rounded-xl resize-none px-6 py-2 my-1"></textarea>
<label class="w-full flex items-start mt-4">
<input required type="checkbox" class="mt-1" />
<span class="text-xs sm:text-sm ml-3">{{ 'Acepto las condiciones legales' | translate | raw }}</span>
</label>
<captcha class="mt-8"></captcha>
<div class="flex justify-center mt-10">
<button type="submit"
class="bg-main-color hover:bg-white text-white hover:text-main-color-dark font-semibold rounded-xl border-2 border-main-color-dark transition3s px-6 py-3">
{{ 'Enviar' | translate }}
</button>
</div>
</c-form>
```
---
## Patrón 5: Compartir en Redes Sociales
```html
<ul class="flex flex-wrap -mx-1 mt-4">
<li class="w-1/2 sm:w-1/3 lg:w-1/5 p-1">
<a href="https://www.facebook.com/sharer.php?u=https://{{ server.HTTP_HOST }}{{ thisrecord.enlace }}"
target="_blank" rel="noopener">
<div class="flex items-center bg-[#306199] text-white rounded-md px-4 py-2.5">Facebook</div>
</a>
</li>
<li class="w-1/2 sm:w-1/3 lg:w-1/5 p-1">
<a href="https://wa.me/?text={{ thisrecord.name }} - https://{{ server.HTTP_HOST }}{{ thisrecord.enlace }}"
target="_blank" rel="noopener">
<div class="flex items-center bg-[#03c100] text-white rounded-md px-4 py-2.5">WhatsApp</div>
</a>
</li>
</ul>
```
---
## Patrón 6: Sección General — Detalle de Producto
```html
<set :tienda="'configuracion_tienda' | get('num !=0')[0]"></set>
<section class="detalle-producto">
<div class="container mx-auto max-w-7xl px-6 2xl:px-0 mt-20 mb-10">
<!-- Imagen con lightbox -->
<div class="relative p-1/5 rounded-xl overflow-hidden" data-aos="zoom-in" data-aos-duration="800">
<a href="{{ thisrecord.foto.0.urlPath }}" class="glightbox">
<img src="{{ thisrecord.foto.0.urlPath | imagec(800) }}" alt="{{ thisrecord.name }}"
class="absolute top-0 left-0 w-full h-full object-cover">
</a>
</div>
<h1 class="text-2xl font-semibold mt-6">{{ thisrecord.name }}</h1>
<span class="text-lg text-gray-600">{{ thisrecord.categoria_bd.0.name }}</span>
<!-- Precio con descuento -->
<div c-if="thisrecord.precio_descuento" class="flex items-center mt-4">
<div class="text-red-500 text-xl line-through">{{ thisrecord.precio }} &euro;</div>
<div class="text-2xl font-semibold ml-4">{{ thisrecord.precio_descuento }} &euro;</div>
</div>
<div c-else class="text-2xl font-semibold mt-4">{{ thisrecord.precio }} &euro;</div>
<div c-if="thisrecord.descripcion" class="wysiwyg mt-6">{{ thisrecord.descripcion | raw }}</div>
<!-- Galería secundaria -->
<div c-if="thisrecord.otras_fotos" class="flex flex-wrap -mx-1 mt-8">
<div c-for="foto in thisrecord.otras_fotos" class="w-1/3 md:w-1/4 p-1">
<a href="{{ foto.urlPath }}" class="glightbox">
<img src="{{ foto.urlPath | imagec(400) }}" alt="{{ thisrecord.name }}" class="w-full h-40 object-cover rounded">
</a>
</div>
</div>
</div>
</section>
<!-- Productos relacionados -->
{% set productosRelacionados = 'productos' | get('categoria = ' ~ thisrecord.categoria ~ ' and num!=' ~ thisrecord.num, 'globalOrder ASC', '3') %}
<section c-if="productosRelacionados" class="py-20 bg-gray-100">
<div class="container mx-auto max-w-7xl px-6 2xl:px-0">
<h2 class="text-3xl text-center mb-10">{{ 'Productos relacionados' | translate }}</h2>
<ul class="flex flex-wrap -mx-4">
<li c-for="producto in productosRelacionados" class="w-full sm:w-1/2 lg:w-1/3 px-4">
<bloqueproducto_i7aunn :producto="producto"></bloqueproducto_i7aunn>
</li>
</ul>
</div>
</section>
```
---
## Patrón 7: Galería con Carousel (modo Gallery)
```html
<div class="c-tns-wrapper" data-autoplay-timeout="8000" data-mode="gallery" data-speed="400" data-nav="true">
<ul class="c-tns-container">
<li data-field-type="uploadMulti" data-field-label="Imagenes" data-field-info1="titulo">
<div class="relative min-h-screen">
<img class="absolute top-0 left-0 w-full h-full object-cover lazyload"
data-src="{{ uploadMulti.urlPath | imagec(2100) }}"
alt="{{ uploadMulti.info1 }}">
</div>
</li>
</ul>
<div class="pointer-events-none c-tns-nav-container absolute bottom-10 left-0 w-full h-full flex justify-center items-end z-20">
<div c-for="imagen in imagenes"
class="select-none pointer-events-auto transition3s flex items-center bg-white bg-opacity-50 cursor-pointer rounded-full w-4 lg:w-5 h-4 lg:h-5 mx-1">
</div>
</div>
</div>
```

85
docs/quick-reference.md Normal file
View File

@@ -0,0 +1,85 @@
# Quick Reference
## Reglas Críticas
| Regla | Correcto | Incorrecto |
|-------|----------|------------|
| Nombres de tabla | `'productos'` | `'cms_productos'` |
| Primary key | `record.num` | `record.id` |
| Foreign keys | `categoria_num` | `categoria_id` |
| Upload fields | `record.imagen[0].urlPath` | `record.imagen` |
| Optimizar imagen | `record.imagen[0].urlPath \| imagec(800)` | `record.imagen.url` |
| Filtros Twig | `{{ 'table' \| get() }}` | `{{ get('table') }}` |
| Campo enlace | `{{ producto.enlace }}` (ya tiene barras) | `"/{{ producto.enlace }}/"` |
| Nombres builder vars | `data-field-label` → sin espacios/especiales, minúsculas | Mantener casing original |
| Checkbox | `1` o `0` (número) | `true`/`false` |
| Formato fecha | `YYYY-MM-DD HH:mm:ss` | Cualquier otro formato |
| c-if igualdad | `c-if="x = 'valor'"` (un `=`) | `c-if="x == 'valor'"` |
| Twig if igualdad | `{% if x == 'valor' %}` (doble `==`) | `{% if x = 'valor' %}` |
| queryDB tablas | `SELECT * FROM cms_tabla` (con prefijo) | `SELECT * FROM tabla` |
| get tablas | `'tabla' \| get()` (sin prefijo) | `'cms_tabla' \| get()` |
## Builder Variable Types
| Type | Elemento | Retorna |
|------|----------|---------|
| `textfield` | `<p>` | String |
| `headfield` | `<h1>`-`<h6>` | String + var `_tag` |
| `textbox` | `<div>` | String multilínea |
| `wysiwyg` | `<div class="wysiwyg">` | HTML string |
| `link` | `<a>` | URL string |
| `upload` | `<img>` | Array de `{urlPath, info1}` |
| `uploadMulti` | `<li>` | Itera archivos subidos |
| `list` (fijo) | `<div data-list-options="...">` | Valor seleccionado |
| `list` (tabla) | `<div data-list-table="...">` | `num` del registro |
| `multiv2` | `<li>` wrapper | Array de objetos |
## Acai HTML Attributes
| Atributo | Uso | Ejemplo |
|----------|-----|---------|
| `c-if` | Condicional | `<p c-if="activo = 1">` |
| `c-else` | Rama else | `<p c-else>` |
| `c-for` | Loop array | `<li c-for="item in items">` |
| `c-for` | Loop tabla | `<li c-for="p in productos" c-where="'activo=1'" c-limit="10">` |
| `c-hidden` | Variable oculta | `<p c-hidden="true" data-field-type="textfield">` |
| `c-class` | Clase condicional | `<div c-class="{ 'bg-red': color == '1' }">` |
| `c-form` | Formulario | `<c-form tableName="'contacto'" captcha="true">` |
## Twig Filters
| Filtro | Uso |
|--------|-----|
| `get` | `'table' \| get(where, order, limit)` |
| `hook` | `'hooks/module_id/' \| hook({params})` |
| `module` | `'module_id' \| module({params})` |
| `queryDB` | `'SELECT ...' \| queryDB()` |
| `imagec` | `path \| imagec(width)` |
| `translate` | `'text' \| translate` |
| `json_decode` | `'json_string' \| json_decode` |
| `raw` | `variable \| raw` |
| `truncate` | `text \| truncate(100)` |
## Formato de datos para registros
| Tipo | Formato | Ejemplo |
|------|---------|---------|
| Text field | String | `"Texto"` |
| Text box | String multilínea | `"Línea 1\nLínea 2"` |
| Date/time | `YYYY-MM-DD HH:mm:ss` | `"2025-12-03 10:30:00"` |
| Wysiwyg | HTML string | `"<p class=\"font-bold\">Texto</p>"` |
| List | String o número | `"activo"` o `"1"` |
| Checkbox | Número 1/0 | `1` o `0` |
| Multivalores | String JSON | `"[{\"producto\":\"1\"}]"` |
| Upload | NO enviar — subir imagen después de crear registro |
## Variables globales
| Variable | Descripción |
|----------|-------------|
| `section_id` | ID único por instancia del módulo |
| `server.HTTP_HOST` | Dominio actual |
| `loop.index` | Índice de iteración (1-based) |
| `loop.index is odd/even` | Para layouts alternados |
| `interno` | True dentro del editor CMS |
| `thisrecord` | Registro actual (en secciones generales) |

209
docs/twig-filters.md Normal file
View File

@@ -0,0 +1,209 @@
# Twig Filters Reference
Acai usa filtros Twig con sintaxis `|`. No usar funciones Twig — solo filtros.
## `get` — Consultar tabla de BD
```twig
{{ 'table_name' | get(where, order, limit) }}
```
- `table_name`: sin prefijo `cms_`
- `where`: string SQL o objeto (opcional)
- `order`: string de orden (opcional)
- `limit`: int (opcional)
```twig
{# Todos los registros #}
{% set products = 'productos' | get() %}
{# Con WHERE string #}
{% set active = 'productos' | get('activo=1') %}
{# Con WHERE objeto #}
{% set active = 'productos' | get({activo: 1}) %}
{# Con WHERE + ORDER + LIMIT #}
{% set latest = 'noticias' | get('publicado=1', 'fecha DESC', 6) %}
{# Completo #}
{% set caros = 'productos' | get('precio > 100', 'precio DESC', 20) %}
{# Single record (primer resultado) #}
{% set product = 'productos' | get({num: 42}) %}
{{ product[0].nombre }}
```
Iterar resultados:
```twig
{% for producto in 'productos' | get('activo=1', 'num DESC', 10) %}
<h3>{{ producto.titulo }}</h3>
{% endfor %}
```
## `queryDB` — SQL directo
Usa nombre de tabla completo WITH prefijo `cms_`.
```twig
{% set results = 'SELECT * FROM cms_productos WHERE precio > 100 ORDER BY precio ASC' | queryDB() %}
{# JOIN complejo #}
{% set top = 'SELECT p.*, COUNT(v.num) as ventas
FROM cms_productos p
LEFT JOIN cms_ventas v ON v.producto_num = p.num
GROUP BY p.num
ORDER BY ventas DESC
LIMIT 5' | queryDB() %}
```
Usar solo cuando `get` no sea suficiente.
## `hook` — Ejecutar PHP Hook
```twig
{# Llamar y mostrar resultado #}
{{ 'hooks/module_id/' | hook({param1: 'value', param2: variable}) }}
{# Capturar en variable #}
{% set result = 'hooks/calcular_precio/' | hook({cantidad: 5, tipo: 'mayoreo'}) %}
<p>Total: ${{ result.total }}</p>
```
## `module` — Renderizar otro módulo
```twig
{{ 'other_module_id' | module({param1: value1}) }}
{# Capturar en variable #}
{% set carrito = 'carrito_compras' | module({usuario_id: 123}) %}
```
## `imagec` — Optimizar/redimensionar imágenes
```twig
{# Redimensionar a ancho #}
<img src="{{ record.image[0].urlPath | imagec(400) }}" />
{# En srcset #}
<img src="{{ record.image[0].urlPath | imagec(800) }}"
srcset="{{ record.image[0].urlPath | imagec(400) }} 400w,
{{ record.image[0].urlPath | imagec(800) }} 800w" />
```
## `translate` — Traducción
```twig
{{ 'Bienvenido' | translate }}
{{ variable | translate }}
```
## `raw` — Renderizar HTML sin escapar
```twig
{{ record.description | raw }}
```
## `truncate` — Truncar texto
```twig
{{ record.description | truncate(150) }}
```
## `json_decode` — Parsear JSON
```twig
{% set data = jsonString | json_decode %}
{{ data.key }}
```
## `split`, `filter` — Filtros estándar Twig
Misma funcionalidad que Twig estándar.
---
## Operadores y Sintaxis
### Concatenación
Twig usa `~` (no `.` ni `+`):
```twig
{{ 'Hello ' ~ name ~ '!' }}
{% set url = '/products/' ~ product.slug ~ '/' %}
```
### Concatenar en filtros
```twig
{% set stock = 'stocks' | get('producto_num=' ~ producto.num) %}
```
### Ternario / Default
```twig
{{ title | default('Default Title') }}
{{ isActive ? 'active' : 'inactive' }}
```
### Comparaciones
```twig
{% if items | length > 0 %}
{% if type == 'premium' %}
{% if name is not empty %}
```
En `c-if` usar `=` (simple). En `{% if %}` usar `==` (doble).
---
## Ejemplos complejos
### Galería con productos y stock
```twig
{% for producto in 'productos' | get('destacado=1', 'num DESC', 12) %}
<div class="producto-card">
<img src="{{ producto.imagen[0].urlPath | imagec(400) }}" alt="{{ producto.titulo }}">
<h3>{{ producto.titulo }}</h3>
<p>{{ producto.descripcion | truncate(100) }}</p>
{% set stock = 'stocks' | get('producto_num=' ~ producto.num) %}
<span>Stock: {{ stock[0].cantidad }}</span>
</div>
{% endfor %}
```
### Múltiples filtros combinados
```twig
{% set categorias = 'categorias' | get() %}
{% set productos = 'productos' | get('activo=1', 'titulo ASC', 20) %}
{% set stats = 'hooks/obtener_stats/' | hook({fecha_inicio: '2024-01-01'}) %}
<h1>{{ stats.titulo | translate }}</h1>
<nav>
{% for cat in categorias %}
<a href="">{{ cat.nombre }}</a>
{% endfor %}
</nav>
{% for prod in productos %}
<div>
<img src="{{ prod.imagen[0].urlPath | imagec(300) }}" alt="">
<h3>{{ prod.titulo }}</h3>
</div>
{% endfor %}
```
---
## Puntos importantes
1. **Solo filtros, no funciones:** `'tabla' | get()` no `get('tabla')`
2. **Upload fields son arrays:** `record.imagen[0].urlPath`, no `record.imagen`
3. **Tablas sin prefijo `cms_`** en `get()`. Con prefijo en `queryDB()`
4. **Concatenar con `~`:** `'stocks' | get('producto_num=' ~ producto.num)`

View File

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

484
docs/vue-builder-rules.md Normal file
View File

@@ -0,0 +1,484 @@
# 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).

265
execute-requirements.md Normal file
View File

@@ -0,0 +1,265 @@
# 🚀 AGENTIC MICROSERVICE MASTER PROMPT V3 (CLAUDE OPUS -- FULL CONTROL MODE)
## SYSTEM ROLE
You are a Principal / Staff Engineer operating at production level.
You specialize in: - Agentic systems - Context engineering - Distributed
backend systems - LLM orchestration - Tool-based architectures (MCP) -
Multi-agent systems
You do NOT produce demos. You build production-grade systems.
------------------------------------------------------------------------
## EXECUTION CONTRACT (STRICT)
You MUST:
- Think like a system architect AND implementer
- Build a COMPLETE system (not partial)
- Avoid unnecessary explanations
- Deliver REAL, executable code
- Avoid placeholders unless unavoidable
- Make decisions without asking unless critical
- Optimize for correctness, extensibility, and clarity
------------------------------------------------------------------------
## GLOBAL OBJECTIVE
Design and IMPLEMENT a production-ready microservice that:
- Manages persistent agent sessions
- Implements advanced context management (NO chat history)
- Connects to MCP via stdio
- Supports multi-model providers
- Implements subagents + orchestrator
- Streams via SSE
- Is dockerized and deployable
------------------------------------------------------------------------
# ⚠️ EXECUTION PHASES (MANDATORY)
You MUST structure your response in EXACTLY these phases:
------------------------------------------------------------------------
## PHASE 1 --- ARCHITECTURE
Define:
- High-level architecture
- Core components
- Data flow
- Trade-offs
- Justify key decisions briefly
DO NOT over-explain.
------------------------------------------------------------------------
## PHASE 2 --- CORE DATA MODELS
Define strongly typed models for:
- SessionState
- TaskState
- ArtifactSummary
- ToolExecution
- MemoryDocument
- ContextPackage
- AgentProfile
- SubAgentDefinition
Use Pydantic v2.
------------------------------------------------------------------------
## PHASE 3 --- CONTEXT ENGINE (CRITICAL)
Implement a professional Context Manager:
It MUST:
- Build prompts from structured state
- Separate:
- immutable_rules
- project_profile
- task_state
- artifact_memory
- working_context
- Never include raw tool outputs
- Perform compaction:
- extract facts
- remove redundancy
- maintain constraints
- Support artifact summarization
- Support selective rehydration
This is the MOST IMPORTANT part of the system.
------------------------------------------------------------------------
## PHASE 4 --- MEMORY SYSTEM
Implement:
- Persistent memory (rules, docs)
- Artifact memory (summaries)
- Optional embeddings (ONLY if useful)
------------------------------------------------------------------------
## PHASE 5 --- MODEL ADAPTER LAYER
Define interface:
``` python
class ModelAdapter:
async def stream(self, messages, tools, config): ...
```
Implement at least ONE real adapter (Claude or OpenAI).
Design for extensibility.
------------------------------------------------------------------------
## PHASE 6 --- MCP CLIENT
Implement stdio-based MCP client:
- process lifecycle
- request/response handling
- timeouts
- tool registry
------------------------------------------------------------------------
## PHASE 7 --- AGENT ORCHESTRATOR
Implement:
- planner agent
- coder agent
- context collector
- reviewer
Include:
- routing logic
- subagent selection
- controlled context per agent
- execution loop
------------------------------------------------------------------------
## PHASE 8 --- SSE STREAMING
Implement:
- SSE endpoint
- event emitter
- structured events:
- session.created
- execution.started
- agent.delta
- tool.started
- tool.completed
- subagent.assigned
- execution.completed
------------------------------------------------------------------------
## PHASE 9 --- API LAYER
Endpoints:
POST /sessions\
POST /sessions/{id}/messages\
GET /sessions/{id}/stream\
GET /sessions/{id}\
DELETE /sessions/{id}
------------------------------------------------------------------------
## PHASE 10 --- REDIS DESIGN
Design structured storage:
- session:{id}
- session:{id}:state
- session:{id}:artifacts
- session:{id}:events
------------------------------------------------------------------------
## PHASE 11 --- FULL IMPLEMENTATION
Provide:
- Full working code
- Clean architecture
- No missing dependencies
- Async-first design
------------------------------------------------------------------------
## PHASE 12 --- DOCKERIZATION
Provide:
- Dockerfile
- Run instructions
------------------------------------------------------------------------
## PHASE 13 --- README
Explain:
- How to run
- How to test
- Example requests
------------------------------------------------------------------------
# ⚠️ CONTEXT RULES (STRICT)
The model MUST NEVER receive:
- full tool outputs
- large logs
- raw code dumps (unless needed for current step)
The model MUST receive:
- summarized state
- minimal working context
- rules
------------------------------------------------------------------------
# ⚠️ ANTI-FAILURE RULES
- Do NOT stop halfway
- Do NOT simplify critical systems
- Do NOT omit context manager logic
- Do NOT skip MCP or orchestrator
- Do NOT produce pseudo-architecture without code
------------------------------------------------------------------------
# SUCCESS CRITERIA
The system must perform:
session → message → plan → tool → summarize → update → stream
------------------------------------------------------------------------
# BEGIN
Execute ALL phases and build the full system.

13
mcp-server/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
.git
.gitignore
.env
.DS_Store
*.log
# We might want monitor.html if it's static, but the code generates it if missing or reads it.
# Let's keep monitor.html if it exists in source, but ignore other junk.
npm-debug.log
Dockerfile
docker-compose.yml
DOCKER_README.md

17
mcp-server/.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Acai MCP Server — Environment Variables
# Copy this file to .env and fill in your values.
# --- API Keys ---
NANO_BANANA_API_KEY=your_nano_banana_key
PIXABAY_API_KEY=your_pixabay_key
PEXELS_API_KEY=your_pexels_key
# --- Security ---
# Generate a strong random secret for JWT signing:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
MCP_JWT_SECRET=change_me_to_a_strong_random_value
# --- Optional: Default Acai credentials ---
# ACAI_TOKEN=your_token
# ACAI_WEBSITE=your_website
# ACAI_TOKEN_HASH=your_token_hash

28
mcp-server/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# Use Node.js 20 Alpine for a lightweight image
FROM node:20-alpine
# Set working directory
WORKDIR /app
# Copy package files first to leverage Docker cache
COPY package.json ./
# Install all dependencies (including devDependencies for nodemon in dev mode)
# Using npm install since package-lock.json is gitignored
RUN npm install
# Copy source code
COPY . .
# Expose the MCP SSE port and monitor UI port
EXPOSE 3000 4545
# Set environment variables (can be overridden in docker-compose for dev)
ENV NODE_ENV=development
# Disable the monitor by default in Docker unless explicitly enabled,
# but since we expose the port, let's assume the user might want it.
# However, for MCP stdio, we must ensure no stray logs go to stdout.
# The application code already handles this by using console.error for logs.
# Command to run the server
CMD ["node", "index.js"]

473
mcp-server/README.md Normal file
View File

@@ -0,0 +1,473 @@
# Acai Code MCP Server
Servidor MCP (Model Context Protocol) para Acai que permite a Claude y otros agentes IA acceder y manipular el código, módulos, tablas y registros de proyectos Acai.
## 📋 Contenido
- [Instalación Local](#instalación-local)
- [Docker (Producción)](#docker-producción)
- [Configuración de Clientes](#configuración-de-clientes)
- [Desarrollo](#desarrollo)
- [Estructura del Proyecto](#estructura-del-proyecto)
- [Troubleshooting](#troubleshooting)
---
## 🚀 Instalación Local
### Requisitos
- Node.js >= 16
- npm o yarn
### Pasos
1. **Instalar dependencias**
```bash
cd server
npm install
```
2. **Iniciar el servidor en modo desarrollo**
```bash
npm start
```
El servidor estará disponible en `http://localhost:3000/sse`
3. **Iniciar con watch mode** (recarga automática)
```bash
npm run dev
```
4. **Verificar que funciona**
```bash
curl http://localhost:3000/health
```
Deberías ver:
```json
{
"status": "ok",
"activeSessions": 0,
"mode": "sse"
}
```
---
## 🐳 Docker (Producción)
### Requisitos
- Docker instalado
- Docker Compose (opcional pero recomendado)
### Construcción de la imagen
```bash
# Desde la carpeta server/
docker build -t acai-mcp-server .
```
### Ejecución
**Opción 1: Docker Compose (Recomendado)**
```bash
docker-compose up -d
```
Esto inicia:
- Servidor MCP en puerto 3000
- Monitor UI en puerto 4545 (opcional)
- Auto-restart habilitado
Detener:
```bash
docker-compose down
```
**Opción 2: Docker directo**
```bash
docker run -d \
--name acai-mcp-server \
--restart unless-stopped \
-p 3000:3000 \
acai-mcp-server
```
### Ver logs
```bash
docker logs acai-mcp-server -f
```
### Parar/Reiniciar
```bash
docker stop acai-mcp-server
docker start acai-mcp-server
docker restart acai-mcp-server
```
---
## 🔧 Configuración de Clientes
### Claude Code (Recomendado)
Crea el archivo `.mcp.json` en la raíz de tu proyecto:
#### Opción A: Con X-User-Token (Simple)
```json
{
"mcpServers": {
"acai-code": {
"command": "npx",
"args": [
"mcp-remote",
"http://localhost:3000/sse",
"--header",
"X-User-Token: {TU_TOKEN_AQUI}"
]
}
}
}
```
#### Opción B: Con X-Acai-Token (Completo)
```json
{
"mcpServers": {
"acai-code": {
"command": "npx",
"args": [
"mcp-remote",
"http://localhost:3000/sse",
"--header",
"X-Acai-Token: {TU_TOKEN_AQUI}",
"--header",
"X-Acai-Token-Hash: {TU_TOKEN_HASH_AQUI}",
"--header",
"X-Acai-Website: {TU_DOMINIO_AQUI}"
]
}
}
}
```
### Obtener credenciales
1. Abre `https://cms.acaisuite.com/admin.php?debug=1`
2. Busca en la consola o en los Network headers:
- **X-User-Token**: Token único (contiene el dominio automáticamente)
- **X-Acai-Token**: Token de sesión
- **X-Acai-Token-Hash**: Hash de validación
- **X-Acai-Website**: Tu dominio
### Servidor remoto
Si el Docker está en otra máquina:
```json
{
"mcpServers": {
"acai-code": {
"command": "npx",
"args": [
"mcp-remote",
"http://192.168.1.100:3000/sse",
"--header",
"X-User-Token: {TU_TOKEN_AQUI}"
]
}
}
}
```
---
## 👨‍💻 Desarrollo
### Estructura
```
server/
├── tools/
│ ├── modules/ # Herramientas para módulos
│ ├── tables/ # Herramientas para tablas
│ ├── records/ # Herramientas para registros
│ ├── files/ # Herramientas para archivos
│ ├── media/ # Herramientas para media
│ ├── auth/ # Herramientas de autenticación
│ └── helpers/ # Utilidades compartidas
├── auth/
│ ├── apiClient.js # Cliente HTTP con auto-login
│ ├── credentials.js # Gestión de credenciales
│ └── index.js # Exportaciones
├── utils/
│ ├── moduleParser.js # Parser de componentes Acai
│ └── remoteParser.js # Parser remoto (appParser)
├── resources/ # Guías y documentación
├── server.js # Punto de entrada principal
├── httpServer.js # Servidor HTTP/SSE
└── package.json
```
### Agregar una nueva herramienta
1. **Crear archivo** `tools/category/toolname.js`:
```javascript
import { z } from "zod";
import { withAuth, getSessionCredentials, getApiClient } from "../../auth/index.js";
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
export function registerMyToolTool(server) {
server.tool(
"my_tool",
"Descripción de la herramienta",
{
param1: z.string().describe("Descripción del parámetro"),
},
withAuth(async ({ param1 }, extra) => {
try {
const credentials = getSessionCredentials(extra.sessionId);
const client = await getApiClient(extra.sessionId);
// Tu lógica aquí
const response = await client.post("/endpoint", {
action_ws: "mi_accion",
});
return {
content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }],
};
} catch (error) {
return handleToolError(error, 'my_tool', { param1 });
}
})
);
}
```
2. **Registrar en** `tools/category/index.js`:
```javascript
import { registerMyToolTool } from './toolname.js';
export function registerCategoryTools(server) {
// ... otras herramientas
registerMyToolTool(server);
}
```
3. **Probar**
```bash
npm run dev
```
### Scripts disponibles
```bash
npm start # Iniciar servidor
npm run dev # Desarrollo con watch
npm test # Ejecutar tests
npm run lint # Verificar linting
```
---
## 📚 Herramientas Disponibles
### Módulos (6)
- `list_modules` - Listar módulos
- `get_module` - Obtener contenido
- `save_module` - Crear/actualizar
- `check_module` - Validar sintaxis
- `check_module_usage` - Ver dónde se usa
- `delete_module` - Eliminar
### Tablas (6)
- `list_tables` - Listar tablas
- `get_table_schema` - Ver estructura
- `create_table` - Crear tabla
- `edit_table_field` - Editar campo
- `delete_table_field` - Eliminar campo
- `get_table_templates` - Obtener templates
### Registros (5)
- `list_records` - Listar registros
- `get_record` - Obtener uno
- `create_record` - Crear
- `update_record` - Actualizar
- `delete_record` - Eliminar
### Archivos (4)
- `list_files` - Listar archivos
- `read_file` - Leer contenido
- `write_file` - Crear/actualizar
- `delete_file` - Eliminar
### Media (3)
- `list_media` - Listar media
- `upload_media` - Subir archivo
- `delete_media` - Eliminar
### Auth (1)
- `get_session_info` - Info de sesión
---
## 🔐 Autenticación
### X-User-Token (Recomendado)
- Token único por usuario
- Incluye automáticamente el dominio
- Simplifica la configuración
- Trigger auto-login en primera petición
### X-Acai-Token + X-Acai-Token-Hash + X-Acai-Website
- Más flexible
- Permite cambiar dominio
- Requiere 3 headers
- Más control
### Auto-login
Si solo envías X-User-Token:
1. Se detecta en la conexión SSE
2. En la primera petición a una herramienta, se hace login
3. Las credenciales se cachean en la sesión
4. Las peticiones posteriores usan el token cacheado
---
## 🐛 Troubleshooting
### Puerto 3000 en uso
```bash
# Encontrar proceso en puerto 3000
lsof -i :3000
# Matar proceso
kill -9 <PID>
# O cambiar puerto
MCP_PORT=3001 npm start
```
### Error: "Token no válido" (403)
- Verifica que el token no ha expirado
- Obtén uno nuevo desde `https://cms.acaisuite.com/admin.php?debug=1`
- Revisa los logs: `docker logs acai-mcp-server -f`
### Error: "window is not defined"
- Asegúrate de pasar `listTables` a `parseComponents()`
- Revisa que `remoteParser.js` tiene las variables seteadas correctamente
### Conexión rechazada
```bash
# Verifica que está corriendo
curl http://localhost:3000/health
# Ver logs
npm run dev
# o
docker logs acai-mcp-server -f
```
### Tools no disponibles
- Verifica headers en `.mcp.json`
- Comprueba que el token es válido
- Revisa los logs del servidor
---
## 📝 Variables de entorno
```bash
MCP_PORT=3000 # Puerto del servidor MCP
MCP_MONITOR_PORT=4545 # Puerto del Monitor UI
MCP_MONITOR_DISABLED=0 # Desactivar Monitor UI
ACAI_TOKEN=... # Token por defecto (no recomendado)
ACAI_WEBSITE=... # Dominio por defecto
ACAI_TOKEN_HASH=... # Hash por defecto
```
---
## 🔄 Actualizar
### Versión local
```bash
git pull origin main
npm install
npm start
```
### Docker
```bash
# Reconstruir imagen
docker build -t acai-mcp-server .
# Reiniciar contenedor
docker restart acai-mcp-server
```
---
## 📖 Recursos
- **Guía Acai**: `resources/guia-programacion-acai.md`
- **Atributos**: `resources/guia-atributos-acai.md`
- **Twig Filters**: `resources/guia-twig-filters.md`
- **Builder Vars**: `resources/guia-builder-vars.md`
- **PHP Hooks**: `resources/guia-php-hooks.md`
---
## 🤝 Contribuir
1. Fork el proyecto
2. Crea una rama: `git checkout -b feature/nueva-herramienta`
3. Haz commit: `git commit -am 'Agregar nueva herramienta'`
4. Push: `git push origin feature/nueva-herramienta`
5. Abre un Pull Request
---
## 📞 Soporte
Para problemas o preguntas:
1. Revisa los logs: `docker logs acai-mcp-server -f`
2. Verifica la configuración en `.mcp.json`
3. Abre un issue en el repositorio
---
## 📄 Licencia
Igual que el proyecto principal de Acai.
---
**Última actualización**: Diciembre 2025

View File

@@ -0,0 +1,195 @@
{
"acai-search-files": {
"description": "Search for text patterns across project files using regex.\n\nSearches through module templates, hooks, sections, styles, and other project files.\nCalls: GET /api/files/search (PENDIENTE: endpoint por crear en el servidor Python).\n\nParameters:\n- query: Regex pattern to find (e.g., \"getVar\\\\(\" to find template variables)\n- include_pattern: Glob filter for files to include (e.g., \"cms/modules/**/*.tpl\")\n- exclude_pattern: Glob filter for files to exclude (e.g., \"**/node_modules/**\")\n- case_sensitive: Whether to match case (default: false)\n\nUseful for finding variable usage across templates, hook references, CSS class usage, etc.",
"parameters": {
"properties": {
"query": {
"example": "getVar\\(",
"type": "string"
},
"include_pattern": {
"example": "cms/modules/**/*.tpl",
"type": "string"
},
"exclude_pattern": {
"example": "**/node_modules/**",
"type": "string"
},
"case_sensitive": {
"example": "false",
"type": "boolean"
}
},
"required": ["query", "include_pattern"],
"type": "object"
}
},
"acai-write": {
"description": "Write content to a project file. Overwrites the existing file if it exists, or creates a new one.\nCalls: POST /api/files/write on the Python server.\n\nThe file path must be relative to the project root (e.g., cms/modules/mi_modulo/index-base.tpl).\n\nIMPORTANT: Prefer acai-line-replace for editing existing files. Use acai-write mainly for:\n- Creating new files (new modules, hooks, styles)\n- Complete file rewrites when most content changes\n\nWhen writing, use \"// ... keep existing code\" comments to preserve unchanged sections:\n- Any unchanged block over 5 lines MUST use this comment\n- The comment MUST contain the exact string \"... keep existing code\"\n- Example: \"// ... keep existing code (hook logic)\"\n\nIf creating multiple files (e.g., a new module with .tpl + .css + .js + hook.php), create all files in parallel.",
"parameters": {
"properties": {
"file_path": {
"example": "cms/modules/hero_banner/index-base.tpl",
"type": "string"
},
"content": {
"example": "<div class=\"hero-banner\">\n <h1>{{getVar('titulo')}}</h1>\n</div>",
"type": "string"
}
},
"required": ["file_path", "content"],
"type": "object"
}
},
"acai-line-replace": {
"description": "Line-based search and replace for editing existing project files.\nThis is the PREFERRED tool for modifying existing code — always use this instead of rewriting entire files with acai-write.\n\nLogic: Reads the file via GET /api/files/read, validates the search content at the specified line range, replaces it, and writes back via POST /api/files/write.\n\nProvide:\n1. file_path — Relative path from project root (e.g., cms/modules/hero/style.css)\n2. search — Content to find (use ... ellipsis for large sections)\n3. first_replaced_line — First line number (1-indexed)\n4. last_replaced_line — Last line number (1-indexed)\n5. replace — New content to replace with\n\nELLIPSIS USAGE (for sections > 6 lines):\n- Include first 2-3 lines of the section\n- Add \"...\" on its own line\n- Include last 2-3 lines\n- Focus on unique context for accurate matching\n\nWhen making multiple edits to the same file in parallel, always use the ORIGINAL line numbers from when you first read the file.\n\nAfter editing an index-base.tpl, you MUST call compile_module to sync changes with the CMS.",
"parameters": {
"properties": {
"file_path": {
"example": "cms/modules/hero_banner/index-base.tpl",
"type": "string"
},
"search": {
"example": "<div class=\"hero-banner\">\n <h1>{{getVar('titulo')}}</h1>\n...\n</div>",
"type": "string"
},
"first_replaced_line": {
"description": "First line number to replace (1-indexed)",
"example": "5",
"type": "number"
},
"last_replaced_line": {
"description": "Last line number to replace (1-indexed)",
"example": "12",
"type": "number"
},
"replace": {
"description": "New content to replace with (without line numbers)",
"example": "<section class=\"hero-banner hero-banner--fullwidth\">\n <h1>{{getVar('titulo')}}</h1>\n <p>{{getVar('subtitulo')}}</p>\n</section>",
"type": "string"
}
},
"required": ["file_path", "search", "first_replaced_line", "last_replaced_line", "replace"],
"type": "object"
}
},
"acai-view": {
"description": "Read the contents of a project file.\nCalls: GET /api/files/read on the Python server.\n\nThe file path must be relative to the project root. You can optionally specify line ranges for large files.\n\nGuidelines:\n- Do NOT use this if the file contents were already provided in context\n- By default reads the first 500 lines. Only use line ranges for large files\n- To read multiple files, invoke this tool multiple times in parallel\n\nCommon files to read:\n- cms/modules/{name}/index-base.tpl — Module HTML template\n- cms/modules/{name}/style.css — Module styles\n- cms/modules/{name}/script.js — Module JavaScript\n- cms/modules/{name}/hook.php — Module PHP hook\n- cms/hooks/{name}.php — Global hooks\n- cms/sections/custom-{name}/index-base.tpl — Custom sections",
"parameters": {
"properties": {
"file_path": {
"example": "cms/modules/hero_banner/index-base.tpl",
"type": "string"
},
"lines": {
"example": "1-100",
"type": "string"
}
},
"required": ["file_path"],
"type": "object"
}
},
"acai-delete": {
"description": "Delete a file from the project.\nCalls: POST /api/files/delete on the Python server.\n\nThe file path must be relative to the project root.\nUse with caution — this action is irreversible. Prefer acai-rename if you want to preserve the file.",
"parameters": {
"properties": {
"file_path": {
"example": "cms/modules/old_module/script.js",
"type": "string"
}
},
"required": ["file_path"],
"type": "object"
}
},
"acai-rename": {
"description": "Rename or move a file within the project.\nCalls: POST /api/files/rename (PENDIENTE: endpoint por crear en el servidor Python).\n\nUse this tool instead of creating a new file and deleting the old one.\nBoth paths must be relative to the project root.",
"parameters": {
"properties": {
"original_file_path": {
"example": "cms/modules/banner/style.css",
"type": "string"
},
"new_file_path": {
"example": "cms/modules/hero_banner/style.css",
"type": "string"
}
},
"required": ["original_file_path", "new_file_path"],
"type": "object"
}
},
"acai-copy": {
"description": "Copy a file or directory to a new location within the project.\nCalls: POST /api/files/copy (PENDIENTE: endpoint por crear en el servidor Python).\n\nUseful for duplicating modules or creating variations of existing templates.\nBoth paths must be relative to the project root.",
"parameters": {
"properties": {
"source_file_path": {
"example": "cms/modules/hero_banner/index-base.tpl",
"type": "string"
},
"destination_file_path": {
"example": "cms/modules/hero_banner_v2/index-base.tpl",
"type": "string"
}
},
"required": ["source_file_path", "destination_file_path"],
"type": "object"
}
},
"acai-read-php-logs": {
"description": "Read PHP and server logs from the project's Docker container.\nCalls: GET /api/logs/{container} on the Python server.\n\nReturns the last 200 lines of the container's log output. You can optionally provide a search query to filter relevant entries.\n\nIMPORTANT:\n- Logs are a snapshot from when the request was made — they do NOT update in real time\n- Do NOT call this more than once per task, as you will get the same results\n- Useful for debugging PHP errors in hooks, module rendering issues, or CMS API failures\n- Cannot verify fixes by re-reading logs — the snapshot won't change",
"parameters": {
"properties": {
"search": {
"description": "Optional text to filter log entries (e.g., 'Fatal error', 'Warning', module name)",
"example": "Fatal error",
"type": "string"
}
},
"required": [],
"type": "object"
}
},
"acai-fetch-website": {
"description": "Fetch a website URL and return its content as markdown, HTML, or screenshot.\nUseful for referencing external designs, documentation, or inspecting the live version of the project.\n\nReturns the content in the requested formats. Use markdown for text extraction, HTML for structure analysis, screenshot for visual reference.",
"parameters": {
"properties": {
"url": {
"example": "https://example.com",
"type": "string"
},
"formats": {
"description": "Comma-separated formats: 'markdown', 'html', 'screenshot'. Defaults to 'markdown'.",
"example": "markdown,screenshot",
"type": "string"
}
},
"required": ["url"],
"type": "object"
}
},
"acai-web-search": {
"description": "Search the web for information relevant to the development task.\n\nUse when you need:\n- Documentation for CSS properties, PHP functions, Twig syntax\n- Design inspiration or reference implementations\n- Troubleshooting specific errors or compatibility issues\n- Information about external APIs or services\n\nSearch tips:\n- Use site:domain.com to filter results (e.g., site:developer.mozilla.org CSS grid)\n- Use quotes for exact phrases (e.g., \"twig template\" getVar)\n- Exclude terms with minus (e.g., flexbox layout -bootstrap)",
"parameters": {
"properties": {
"query": {
"description": "Search query",
"example": "site:developer.mozilla.org CSS container queries",
"type": "string"
},
"numResults": {
"description": "Number of results to return (default: 5)",
"example": "5",
"type": "number"
},
"category": {
"description": "Optional category filter: 'news', 'github', 'pdf'",
"type": "string"
}
},
"required": ["query"],
"type": "object"
}
}
}

View File

@@ -0,0 +1,142 @@
import axios from "axios";
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import { sessionApiClients, getSessionCredentials, setCredentials, findRoleByToken } from "./credentials.js";
import { assertSafeCmsTarget } from "../utils/cmsTargetSafety.js";
const DEFAULT_ROLE = 'developer';
/**
* Check if session is configured with valid credentials
*/
export const ensureConfigured = async (sessionId) => {
const creds = await getSessionCredentials(sessionId);
if (!creds.token || !creds.web_url || !creds.api_web_url) {
throw new McpError(
ErrorCode.InvalidRequest,
"Acai site not configured for safe local execution. Use the project MCP config/select_project flow so ACAI_API_WEB_URL points to the local environment."
);
}
try {
assertSafeCmsTarget(creds, "apiClient");
} catch (error) {
throw new McpError(
ErrorCode.InvalidRequest,
error.message
);
}
};
/**
* Rebuild API client for a session
*/
export const rebuildApiClient = async (sessionId) => {
const creds = await getSessionCredentials(sessionId);
if (!creds.token || !creds.web_url || !creds.api_web_url) {
return null;
}
assertSafeCmsTarget(creds, "apiClient");
const client = axios.create({
baseURL: creds.api_web_url,
headers: {
"Content-Type": "application/json",
"X-Acai-Token": creds.token,
...(creds.forge_host ? { Host: creds.forge_host } : {}),
},
});
// Request interceptor: always send latest token
client.interceptors.request.use((config) => {
if (creds.token) {
config.headers["X-Acai-Token"] = creds.token;
}
return config;
});
sessionApiClients.set(sessionId, client);
return client;
};
/**
* Get or create API client for a session
* @param {string} sessionId - The session ID
*/
export const getApiClient = async (sessionId) => {
const creds = await getSessionCredentials(sessionId);
console.error(`[API Client] getApiClient called for session ${sessionId}`);
console.error(`[API Client] Current creds: token=${!!creds.token}, web_url=${creds.web_url}`);
await ensureConfigured(sessionId);
let client = sessionApiClients.get(sessionId);
if (!client) {
console.error(`[API Client] No cached client, rebuilding...`);
client = await rebuildApiClient(sessionId);
}
if (!client) {
throw new McpError(
ErrorCode.InvalidRequest,
"Unable to create API client. Verify credentials and try again."
);
}
console.error(`[API Client] Returning client for session ${sessionId}`);
return client;
};
/**
* Set credentials and rebuild API client
* @param {Object} credentials - The credentials object
* @param {string} sessionId - The session ID
* @param {string} mcpSessionId - Optional MCP-Session-Id for persistence across SSE reconnections
*/
export const setCredentialsAndRebuild = async (credentials, sessionId, mcpSessionId = null) => {
await setCredentials(credentials, sessionId, mcpSessionId);
await rebuildApiClient(sessionId);
};
/**
* Wrapper for authenticated handlers
* Supports both session-based auth and inline credentials (stateless mode).
*
* If args contains acaiToken + acaiWebsite, these are used directly,
* allowing Claude to send credentials with each request.
*/
export const withAuth = (handler) => {
return async (args, extra) => {
const sessionId = extra?.sessionId || "_default";
console.error(`[withAuth] Called with sessionId: ${sessionId}`);
// Check for inline credentials (stateless mode)
const inlineCredentials = {
acaiToken: args.acaiToken,
acaiWebsite: args.acaiWebsite,
acaiTokenHash: args.acaiTokenHash
};
const hasInlineCredentials = inlineCredentials.acaiToken && inlineCredentials.acaiWebsite;
if (hasInlineCredentials) {
// Lookup role by token before storing credentials
const role = findRoleByToken(inlineCredentials.acaiToken) || DEFAULT_ROLE;
console.error(`[withAuth] Using INLINE credentials: website=${inlineCredentials.acaiWebsite}, role=${role}`);
// Temporarily store inline credentials in session for this request
await setCredentials({
token: inlineCredentials.acaiToken,
website: inlineCredentials.acaiWebsite,
web_url: `https://${inlineCredentials.acaiWebsite}`,
api_web_url: null,
forge_host: null,
tokenHash: inlineCredentials.acaiTokenHash || null,
profileName: 'inline',
role: role
}, sessionId);
}
console.error(`[withAuth] Getting API client for session ${sessionId}...`);
await getApiClient(sessionId);
console.error(`[withAuth] API client ready, calling handler...`);
return handler(args, { ...extra, sessionId, inlineCredentials: hasInlineCredentials ? inlineCredentials : null });
};
};

View File

@@ -0,0 +1,251 @@
/**
* Session-based credentials management
*
* IMPORTANT: Each session is completely isolated.
* Credentials are stored ONLY by sessionId, never shared between sessions.
* This prevents credential leakage when multiple Claude tabs connect to different websites.
*
* AUTH TOKEN PERSISTENCE:
* Claude MCP frequently reconnects SSE, creating new sessionIds each time.
* To maintain credentials across reconnections, we also index by authToken
* (the SimpleAuth header that Claude sends with each request).
*/
const DEFAULT_ROLE = 'developer';
// Session-based credentials storage (ephemeral, per-session)
export const sessionCredentials = new Map();
export const sessionApiClients = new Map();
export const sessionUserTokens = new Map();
// Map sessionId -> McpServer instance (for role-based tool filtering)
export const sessionServers = new Map();
// MCP-Session-Id -> credentials mapping for persistence across SSE reconnections
// This is the standard MCP mechanism for session persistence
// Key: MCP-Session-Id (UUID), Value: { credentials, lastAccess }
export const mcpSessionCredentials = new Map();
// TTL for MCP session credentials (30 minutes - longer than authToken since it's per-conversation)
const MCP_SESSION_TTL_MS = 30 * 60 * 1000;
/**
* Clean up expired MCP session credentials
*/
const cleanupExpiredMcpSessions = () => {
const now = Date.now();
for (const [mcpSessionId, data] of mcpSessionCredentials.entries()) {
if (now - data.lastAccess > MCP_SESSION_TTL_MS) {
console.error(`[Credentials] Cleaning up expired MCP-Session-Id ${mcpSessionId.substring(0, 8)}... (age: ${Math.round((now - data.lastAccess) / 1000)}s)`);
mcpSessionCredentials.delete(mcpSessionId);
}
}
};
// Run cleanup every 5 minutes
setInterval(cleanupExpiredMcpSessions, 5 * 60 * 1000);
/**
* Get credentials by MCP-Session-Id
*/
export const getMcpSessionCredentials = (mcpSessionId) => {
const data = mcpSessionCredentials.get(mcpSessionId);
if (data) {
data.lastAccess = Date.now();
console.error(`[Credentials] getMcpSessionCredentials(${mcpSessionId.substring(0, 8)}...) - FOUND: website=${data.credentials.website}`);
return data.credentials;
}
console.error(`[Credentials] getMcpSessionCredentials(${mcpSessionId.substring(0, 8)}...) - NOT FOUND`);
return null;
};
/**
* Set credentials by MCP-Session-Id
*/
export const setMcpSessionCredentials = (mcpSessionId, credentials) => {
mcpSessionCredentials.set(mcpSessionId, {
credentials,
lastAccess: Date.now()
});
console.error(`[Credentials] setMcpSessionCredentials(${mcpSessionId.substring(0, 8)}...) - website=${credentials.website} (total: ${mcpSessionCredentials.size})`);
};
/**
* Find role by token lookup in session storage
* @param {string} token - The acaiToken to lookup
* @returns {string|null} The role associated with this token, or null if not found
*/
export const findRoleByToken = (token) => {
if (!token) return null;
// Search in sessionCredentials Map
for (const [sessionId, creds] of sessionCredentials.entries()) {
if (creds.token === token && creds.role) {
console.error(`[Credentials] findRoleByToken - FOUND role=${creds.role} for token in session ${sessionId}`);
return creds.role;
}
}
// Search in mcpSessionCredentials Map
for (const [mcpSessionId, data] of mcpSessionCredentials.entries()) {
if (data.credentials.token === token && data.credentials.role) {
console.error(`[Credentials] findRoleByToken - FOUND role=${data.credentials.role} for token in MCP session ${mcpSessionId.substring(0, 8)}...`);
return data.credentials.role;
}
}
console.error(`[Credentials] findRoleByToken - NOT FOUND for token`);
return null;
};
/**
* Get credentials for a specific session
* Supports inline credentials that take priority over session credentials.
* This allows Claude to send credentials with each request (stateless mode).
*
* @param {string} sessionId - The session ID
* @param {Object} inlineCredentials - Optional inline credentials from tool params
* @param {string} inlineCredentials.acaiToken - Token passed directly in tool call
* @param {string} inlineCredentials.acaiWebsite - Website passed directly in tool call
* @param {string} inlineCredentials.acaiTokenHash - Token hash passed directly in tool call
*/
export const getSessionCredentials = async (sessionId, inlineCredentials = null) => {
// Priority 1: Inline credentials (stateless mode - Claude sends token with each request)
if (inlineCredentials?.acaiToken && inlineCredentials?.acaiWebsite) {
// Lookup role by token in session storage
const role = findRoleByToken(inlineCredentials.acaiToken);
console.error(`[Credentials] getSessionCredentials(${sessionId}) - USING INLINE: website=${inlineCredentials.acaiWebsite}, role=${role || 'not found'}`);
return {
token: inlineCredentials.acaiToken,
website: inlineCredentials.acaiWebsite,
web_url: `https://${inlineCredentials.acaiWebsite}`,
api_web_url: null,
forge_host: null,
tokenHash: inlineCredentials.acaiTokenHash || null,
profileName: 'inline',
role: role || DEFAULT_ROLE // Merge role from session storage, fallback to default
};
}
// Priority 2: Session credentials
const sessionCreds = sessionCredentials.get(sessionId);
if (sessionCreds) {
console.error(`[Credentials] getSessionCredentials(${sessionId}) - FOUND: website=${sessionCreds.website}, hasToken=${!!sessionCreds.token}`);
return sessionCreds;
}
// Priority 3: Fallback to environment variables (for backwards compatibility)
console.error(`[Credentials] getSessionCredentials(${sessionId}) - NOT FOUND, using env fallback`);
console.error(`[Credentials] Active sessions: [${Array.from(sessionCredentials.keys()).join(', ')}]`);
console.error(`[Credentials] Active MCP sessions: ${mcpSessionCredentials.size}`);
const envWebsite = process.env.ACAI_WEBSITE || null;
return {
token: process.env.ACAI_TOKEN || null,
website: envWebsite,
web_url: process.env.ACAI_WEB_URL || (envWebsite ? `https://${envWebsite}` : null),
api_web_url: process.env.ACAI_API_WEB_URL || null,
forge_host: process.env.ACAI_FORGE_HOST || null,
tokenHash: process.env.ACAI_TOKEN_HASH || null,
profileName: 'default',
role: 'developer', // Env fallback = local dev, full access
};
};
/**
* Get X-User-Token for a specific session (for fallback login)
*/
export const getSessionUserToken = (sessionId) => {
return sessionUserTokens.get(sessionId) || null;
};
/**
* Set X-User-Token for a specific session
*/
export const setSessionUserToken = (userToken, sessionId) => {
if (userToken) {
sessionUserTokens.set(sessionId, userToken);
}
};
/**
* Set credentials for a specific session
* Credentials are stored by sessionId AND by mcpSessionId (for SSE reconnection persistence).
* @param {Object} credentials - The credentials object
* @param {string} sessionId - The session ID (SSE transport session)
* @param {string} mcpSessionId - Optional MCP-Session-Id for persistence across SSE reconnections
*/
export const setCredentials = async ({ website, web_url, api_web_url, forge_host, token, tokenHash, profileName, role }, sessionId, mcpSessionId = null) => {
console.error(`[Credentials] setCredentials(${sessionId}) - website=${website}, web_url=${web_url}, api_web_url=${api_web_url}, forge_host=${forge_host || ""}, hasToken=${!!token}, hasTokenHash=${!!tokenHash}, profile=${profileName || "manual"}, role=${role || 'default'}, hasMcpSessionId=${!!mcpSessionId}`);
const creds = {
website,
web_url: web_url || (website ? `https://${website}` : null),
api_web_url: api_web_url || null,
forge_host: forge_host || null,
token,
tokenHash,
profileName: profileName || "manual",
role: role || DEFAULT_ROLE,
};
// Store by sessionId
sessionCredentials.set(sessionId, creds);
// Also store by MCP-Session-Id for persistence across SSE reconnections
if (mcpSessionId) {
setMcpSessionCredentials(mcpSessionId, creds);
}
// Verify it was set
const verify = sessionCredentials.get(sessionId);
console.error(`[Credentials] Verification: exists=${!!verify}, website=${verify?.website}`);
};
/**
* Clear credentials for a session
* NOTE: We only clear the sessionId mappings, NOT the authToken mappings.
* This allows credentials to persist across SSE reconnections.
* AuthToken credentials will be cleaned up by the TTL cleanup routine.
*/
export const clearSessionCredentials = (sessionId) => {
console.error(`[Credentials] Clearing session ${sessionId} (authToken credentials preserved for reconnection)`);
sessionCredentials.delete(sessionId);
sessionApiClients.delete(sessionId);
sessionUserTokens.delete(sessionId);
sessionServers.delete(sessionId);
// NOTE: We intentionally do NOT clear authTokenCredentials here
// to allow credentials to persist across SSE reconnections
};
/**
* Get common params for API requests
* @param {string} sessionId - The session ID
* @param {Object} extraParams - Extra parameters to include
*/
export const getCommonParams = async (sessionId, extraParams = {}) => {
const creds = await getSessionCredentials(sessionId);
const params = {
token: creds.token,
...extraParams
};
if (creds.tokenHash) {
params.tokenHash = creds.tokenHash;
}
return params;
};
/**
* Extract MCP-Session-Id from request headers
* @param {Object} extra - The extra object passed to tool handlers
* @returns {string|null} The MCP-Session-Id or null
*/
export const extractMcpSessionId = (extra) => {
const headers = extra?.requestInfo?.headers;
if (!headers) return null;
return headers['mcp-session-id'] || null;
};

26
mcp-server/auth/index.js Normal file
View File

@@ -0,0 +1,26 @@
export {
sessionCredentials,
sessionApiClients,
sessionServers,
mcpSessionCredentials,
getSessionCredentials,
setCredentials,
clearSessionCredentials,
getCommonParams,
getSessionUserToken,
setSessionUserToken,
getMcpSessionCredentials,
setMcpSessionCredentials,
extractMcpSessionId
} from './credentials.js';
export {
ensureConfigured,
rebuildApiClient,
getApiClient,
setCredentialsAndRebuild,
withAuth
} from './apiClient.js';
export { fetchProjectInfo, fetchProjectsList } from './localClient.js';

View File

@@ -0,0 +1,14 @@
import axios from "axios";
import { LOCAL_SERVER_URL } from "../config/index.js";
export async function fetchProjectInfo(projectName) {
const response = await axios.get(`${LOCAL_SERVER_URL}/api/mcp/project-info`, {
params: { project: projectName }
});
return response.data; // { success, web_url, token, tokenHash, domain, project_dir }
}
export async function fetchProjectsList() {
const response = await axios.get(`${LOCAL_SERVER_URL}/api/mcp/projects`);
return response.data; // { success, projects: [...] }
}

View File

@@ -0,0 +1,207 @@
/**
* Redis Client for Session Persistence
*
* Provides persistent storage for user credentials across server restarts.
* Falls back to in-memory Map if Redis is unavailable.
*/
import { createClient } from 'redis';
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const USER_CACHE_TTL = 30 * 60; // 30 minutes in seconds
let redisClient = null;
let redisAvailable = false;
// Fallback in-memory cache (used if Redis is unavailable)
const memoryCache = new Map();
/**
* Initialize Redis client
*/
export async function initRedis() {
try {
console.error('[Redis] Connecting to Redis at', REDIS_URL);
redisClient = createClient({
url: REDIS_URL,
socket: {
connectTimeout: 5000,
reconnectStrategy: (retries) => {
if (retries > 3) {
console.error('[Redis] Max reconnection attempts reached, falling back to memory cache');
redisAvailable = false;
return false; // Stop reconnecting
}
return Math.min(retries * 100, 3000);
}
}
});
redisClient.on('error', (err) => {
console.error('[Redis] Error:', err.message);
redisAvailable = false;
});
redisClient.on('connect', () => {
console.error('[Redis] Connected successfully');
redisAvailable = true;
});
redisClient.on('reconnecting', () => {
console.error('[Redis] Reconnecting...');
});
await redisClient.connect();
redisAvailable = true;
console.error('[Redis] Ready to use');
} catch (error) {
console.error('[Redis] Failed to initialize:', error.message);
console.error('[Redis] Falling back to in-memory cache');
redisAvailable = false;
}
}
/**
* Set user credentials in cache (Redis or memory)
*/
export async function setUserCredentials(userIdentifier, credentials) {
if (!userIdentifier) {
console.error('[Redis] Cannot set credentials: no userIdentifier');
return false;
}
const key = `user:creds:${userIdentifier}`;
const value = JSON.stringify({
...credentials,
lastUsed: Date.now()
});
if (redisAvailable && redisClient) {
try {
await redisClient.setEx(key, USER_CACHE_TTL, value);
console.error(`[Redis] Saved credentials for user ${userIdentifier} (TTL: ${USER_CACHE_TTL}s)`);
return true;
} catch (error) {
console.error('[Redis] Error saving to Redis:', error.message);
console.error('[Redis] Falling back to memory cache for this operation');
redisAvailable = false;
}
}
// Fallback to memory cache
memoryCache.set(key, {
value,
expiresAt: Date.now() + (USER_CACHE_TTL * 1000)
});
console.error(`[Redis] Saved credentials for user ${userIdentifier} to memory cache`);
return true;
}
/**
* Get user credentials from cache (Redis or memory)
*/
export async function getUserCredentials(userIdentifier) {
if (!userIdentifier) {
return null;
}
const key = `user:creds:${userIdentifier}`;
if (redisAvailable && redisClient) {
try {
const value = await redisClient.get(key);
if (value) {
console.error(`[Redis] Retrieved credentials for user ${userIdentifier} from Redis`);
const creds = JSON.parse(value);
// Update lastUsed timestamp
await setUserCredentials(userIdentifier, {
website: creds.website,
token: creds.token,
tokenHash: creds.tokenHash,
profileName: creds.profileName
});
return creds;
}
} catch (error) {
console.error('[Redis] Error reading from Redis:', error.message);
console.error('[Redis] Falling back to memory cache');
redisAvailable = false;
}
}
// Fallback to memory cache
const cached = memoryCache.get(key);
if (cached) {
if (Date.now() < cached.expiresAt) {
console.error(`[Redis] Retrieved credentials for user ${userIdentifier} from memory cache`);
const creds = JSON.parse(cached.value);
// Update expiration
memoryCache.set(key, {
value: cached.value,
expiresAt: Date.now() + (USER_CACHE_TTL * 1000)
});
return creds;
} else {
console.error(`[Redis] Memory cache expired for user ${userIdentifier}`);
memoryCache.delete(key);
}
}
return null;
}
/**
* Delete user credentials from cache
*/
export async function deleteUserCredentials(userIdentifier) {
if (!userIdentifier) {
return;
}
const key = `user:creds:${userIdentifier}`;
if (redisAvailable && redisClient) {
try {
await redisClient.del(key);
console.error(`[Redis] Deleted credentials for user ${userIdentifier} from Redis`);
} catch (error) {
console.error('[Redis] Error deleting from Redis:', error.message);
}
}
// Also delete from memory cache
memoryCache.delete(key);
console.error(`[Redis] Deleted credentials for user ${userIdentifier} from memory cache`);
}
/**
* Get Redis health status
*/
export function getRedisStatus() {
return {
available: redisAvailable,
connected: redisClient?.isOpen || false,
url: REDIS_URL,
fallbackCacheSize: memoryCache.size
};
}
/**
* Close Redis connection (for graceful shutdown)
*/
export async function closeRedis() {
if (redisClient) {
try {
await redisClient.quit();
console.error('[Redis] Connection closed');
} catch (error) {
console.error('[Redis] Error closing connection:', error.message);
}
}
}

44
mcp-server/cluster.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* Acai Code MCP Server - Cluster Mode
*
* Ejecuta múltiples workers para aprovechar todos los CPUs.
* Cada worker maneja sus propias conexiones SSE.
*/
import cluster from 'node:cluster';
import os from 'node:os';
const numCPUs = os.cpus().length;
// Usar máximo 4 workers o el número de CPUs, lo que sea menor
const numWorkers = Math.min(numCPUs, 4);
if (cluster.isPrimary) {
console.log(`[Cluster] Master ${process.pid} iniciando`);
console.log(`[Cluster] CPUs disponibles: ${numCPUs}`);
console.log(`[Cluster] Spawneando ${numWorkers} workers...`);
// Spawnear workers
for (let i = 0; i < numWorkers; i++) {
cluster.fork();
}
// Reiniciar worker si muere
cluster.on('exit', (worker, code, signal) => {
console.error(`[Cluster] Worker ${worker.process.pid} murió (${signal || code}). Reiniciando...`);
cluster.fork();
});
// Log cuando un worker está listo
cluster.on('online', (worker) => {
console.log(`[Cluster] Worker ${worker.process.pid} online`);
});
} else {
// Worker: ejecutar el servidor MCP
import('./index.js').then(() => {
console.log(`[Worker ${process.pid}] MCP Server iniciado`);
}).catch((err) => {
console.error(`[Worker ${process.pid}] Error:`, err);
process.exit(1);
});
}

102
mcp-server/config/index.js Normal file
View File

@@ -0,0 +1,102 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load .env from server directory
dotenv.config({ path: path.join(__dirname, '..', '.env') });
export const CONFIG_FILE_PATH =
process.env.ACAI_CONFIG_PATH ||
path.join(__dirname, "..", "..", "mcp-config.json");
export const MCP_PORT = Number(process.env.MCP_PORT || 3000);
export const MONITOR_PORT = Number(process.env.MCP_MONITOR_PORT || 4545);
export const MONITOR_DISABLED =
String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "1" ||
String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "true";
export const JWT_SECRET =
process.env.MCP_JWT_SECRET ||
process.env.JWT_SECRET ||
"change_me_in_env";
if (JWT_SECRET === "change_me_in_env") {
console.warn("[config] WARNING: JWT_SECRET is using the default value. Set MCP_JWT_SECRET or JWT_SECRET in your .env file for production.");
}
export const LOCAL_SERVER_URL = process.env.LOCAL_SERVER_URL || 'http://localhost:29871';
// Auth headers para llamadas internas al server Python
export function getLocalServerHeaders() {
const headers = { "Content-Type": "application/json" };
// En Forge, usar X-Acai-Token para auth interna
const token = process.env.ACAI_TOKEN || "";
const website = process.env.ACAI_WEBSITE || "";
if (token && website) {
headers["X-Acai-Token"] = token;
headers["X-Acai-Website"] = website;
}
return headers;
}
export const SAAS_URL = "https://ws.cocosolution.com/api/schemas/";
export const CMS_URL = "https://acai.cms.cocosolution.com";
const selectProfile = (config) => {
if (!config || typeof config !== "object") {
return null;
}
if (config.token && config.website) {
return { ...config, profileName: config.profileName || "default" };
}
const profiles = config.profiles || {};
const profileKey =
process.env.ACAI_PROFILE ||
config.defaultProfile ||
Object.keys(profiles)[0];
if (profileKey && profiles[profileKey]) {
return {
...profiles[profileKey],
profileName: profileKey,
};
}
return null;
};
export const loadLocalConfigProfile = () => {
if (!fs.existsSync(CONFIG_FILE_PATH)) {
return null;
}
try {
const raw = fs.readFileSync(CONFIG_FILE_PATH, "utf-8");
const parsed = JSON.parse(raw);
return selectProfile(parsed);
} catch (error) {
console.error(`[config] Could not read ${CONFIG_FILE_PATH}: ${error.message}`);
return null;
}
};
export const applyProfileToEnv = (profile) => {
if (!profile) {
return;
}
if (!process.env.ACAI_TOKEN && profile.token) {
process.env.ACAI_TOKEN = profile.token;
}
if (!process.env.ACAI_TOKEN_HASH && profile.tokenHash) {
process.env.ACAI_TOKEN_HASH = profile.tokenHash;
}
if (!process.env.ACAI_WEBSITE && profile.website) {
process.env.ACAI_WEBSITE = profile.website;
}
console.error(`[config] Loaded Acai profile '${profile.profileName}' from ${CONFIG_FILE_PATH}`);
};

263
mcp-server/debug_client.py Normal file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""
Cliente debug para el MCP server via stdio.
Arranca el servidor como subprocess y te deja mandarle JSON-RPC interactivamente.
Uso:
python3 debug_client.py [proyecto_dir]
Ejemplo:
python3 debug_client.py /Users/jordandiaz/webs-locales/keepsailing.es
Comandos especiales:
init - Manda initialize + initialized automaticamente
tools - Lista las tools disponibles
call - Modo interactivo para llamar una tool
prompts - Lista los prompts
resources - Lista los resources
quit/exit - Salir
"""
import subprocess
import json
import sys
import os
import threading
import time
# Colores para la terminal
GREEN = "\033[92m"
CYAN = "\033[96m"
YELLOW = "\033[93m"
RED = "\033[91m"
DIM = "\033[2m"
RESET = "\033[0m"
class McpDebugClient:
def __init__(self, project_dir=""):
self.project_dir = project_dir
self.msg_id = 0
self.proc = None
self.responses = {}
self._reader_thread = None
def start(self):
"""Arranca el proceso MCP stdio."""
env = os.environ.copy()
if self.project_dir:
env["ACAI_PROJECT_DIR"] = self.project_dir
mcp_file = os.path.join(self.project_dir, ".mcp.json")
if os.path.exists(mcp_file):
try:
with open(mcp_file) as f:
data = json.load(f)
server = data.get("mcpServers", {}).get("acai-code", {})
server_env = server.get("env", {})
for key in (
"ACAI_WEBSITE",
"ACAI_WEB_URL",
"ACAI_API_WEB_URL",
"ACAI_FORGE_HOST",
"ACAI_TOKEN",
"ACAI_TOKEN_HASH",
"ACAI_PROJECT_DIR",
):
if server_env.get(key):
env.setdefault(key, server_env[key])
print(
f"{GREEN}Leido .mcp.json:{RESET} "
f"website={env.get('ACAI_WEBSITE')}, "
f"web_url={env.get('ACAI_WEB_URL')}, "
f"api_web_url={env.get('ACAI_API_WEB_URL')}"
)
except Exception as e:
print(f"{YELLOW}No se pudo leer .mcp.json: {e}{RESET}")
# Fallback a .acai para sacar website, token y una URL basica si no hay .mcp.json
acai_file = os.path.join(self.project_dir, ".acai")
if os.path.exists(acai_file):
try:
with open(acai_file) as f:
data = json.load(f)
website = data.get("website") or data.get("domain") or ""
web_url = data.get("web_url") or data.get("webUrl") or ""
if not web_url and website:
scheme = "https" if data.get("ssl", True) else "http"
web_url = f"{scheme}://{website}"
env.setdefault("ACAI_WEBSITE", website)
env.setdefault("ACAI_WEB_URL", web_url)
if data.get("token"):
env.setdefault("ACAI_TOKEN", data["token"])
if data.get("tokenHash"):
env.setdefault("ACAI_TOKEN_HASH", data["tokenHash"])
print(f"{GREEN}Leido .acai:{RESET} website={env.get('ACAI_WEBSITE')}, web_url={env.get('ACAI_WEB_URL')}")
except Exception as e:
print(f"{YELLOW}No se pudo leer .acai: {e}{RESET}")
script_dir = os.path.dirname(os.path.abspath(__file__))
stdio_path = os.path.join(script_dir, "stdio.js")
print(f"{DIM}Arrancando: node {stdio_path}{RESET}")
self.proc = subprocess.Popen(
["node", stdio_path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
cwd=script_dir,
)
# Hilo para leer stderr (logs del server)
self._reader_thread = threading.Thread(target=self._read_stderr, daemon=True)
self._reader_thread.start()
# Dar tiempo a que arranque
time.sleep(1)
def _read_stderr(self):
"""Lee stderr del server y lo muestra."""
for line in self.proc.stderr:
text = line.decode("utf-8", errors="replace").rstrip()
if text:
print(f"{DIM}[server] {text}{RESET}")
def send(self, obj):
"""Envia un mensaje JSON-RPC al server."""
raw = json.dumps(obj)
msg = raw + "\n"
print(f"\n{CYAN}>>> Enviando:{RESET}")
print(json.dumps(obj, indent=2, ensure_ascii=False))
self.proc.stdin.write(msg.encode("utf-8"))
self.proc.stdin.flush()
def recv(self, timeout=10):
"""Lee una respuesta del server."""
self.proc.stdout.flush()
line = self.proc.stdout.readline().decode("utf-8", errors="replace")
if not line:
return None
obj = json.loads(line)
print(f"\n{GREEN}<<< Respuesta:{RESET}")
print(json.dumps(obj, indent=2, ensure_ascii=False))
return obj
def request(self, method, params=None):
"""Envia un request y espera la respuesta."""
self.msg_id += 1
msg = {"jsonrpc": "2.0", "id": self.msg_id, "method": method}
if params is not None:
msg["params"] = params
self.send(msg)
return self.recv()
def notify(self, method, params=None):
"""Envia una notificacion (sin id, no espera respuesta)."""
msg = {"jsonrpc": "2.0", "method": method}
if params is not None:
msg["params"] = params
self.send(msg)
def initialize(self):
"""Handshake completo: initialize + initialized."""
print(f"\n{YELLOW}=== Inicializando ==={RESET}")
resp = self.request("initialize", {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "debug_client", "version": "1.0.0"}
})
self.notify("notifications/initialized")
print(f"\n{GREEN}Inicializado OK{RESET}")
if resp and "result" in resp:
caps = resp["result"].get("capabilities", {})
if caps.get("tools"):
print(f" Tools: disponibles")
if caps.get("prompts"):
print(f" Prompts: disponibles")
if caps.get("resources"):
print(f" Resources: disponibles")
return resp
def list_tools(self):
"""Lista las tools."""
resp = self.request("tools/list")
if resp and "result" in resp:
tools = resp["result"].get("tools", [])
print(f"\n{YELLOW}=== {len(tools)} tools ==={RESET}")
for t in tools:
desc = t.get("description", "")[:60]
print(f" {GREEN}{t['name']}{RESET} - {desc}")
return resp
def call_tool(self):
"""Modo interactivo para llamar una tool."""
name = input(f"{CYAN}Nombre de la tool: {RESET}").strip()
if not name:
return
print(f"Argumentos como JSON (enter para {{}}):")
args_str = input(f"{CYAN}> {RESET}").strip()
args = json.loads(args_str) if args_str else {}
return self.request("tools/call", {"name": name, "arguments": args})
def stop(self):
if self.proc:
self.proc.terminate()
self.proc.wait()
def main():
project_dir = sys.argv[1] if len(sys.argv) > 1 else ""
print(f"{YELLOW}╔══════════════════════════════════╗{RESET}")
print(f"{YELLOW}║ MCP Debug Client (stdio) ║{RESET}")
print(f"{YELLOW}╚══════════════════════════════════╝{RESET}")
print()
print("Comandos: init, tools, call, prompts, resources, json, quit")
print()
client = McpDebugClient(project_dir)
client.start()
try:
while True:
try:
cmd = input(f"\n{CYAN}mcp> {RESET}").strip().lower()
except EOFError:
break
if not cmd:
continue
elif cmd in ("quit", "exit", "q"):
break
elif cmd == "init":
client.initialize()
elif cmd == "tools":
client.list_tools()
elif cmd == "call":
client.call_tool()
elif cmd == "prompts":
client.request("prompts/list")
elif cmd == "resources":
client.request("resources/list")
elif cmd == "json":
print("Pega el JSON-RPC completo:")
raw = input(f"{CYAN}> {RESET}").strip()
try:
obj = json.loads(raw)
if "id" not in obj:
client.notify(obj.get("method"), obj.get("params"))
else:
client.send(obj)
client.recv()
except json.JSONDecodeError as e:
print(f"{RED}JSON invalido: {e}{RESET}")
else:
print(f"{YELLOW}Comando no reconocido. Usa: init, tools, call, prompts, resources, json, quit{RESET}")
except KeyboardInterrupt:
pass
finally:
print(f"\n{DIM}Cerrando...{RESET}")
client.stop()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
name: acai-code-mcp-prod
services:
redis:
image: redis:7-alpine
container_name: acai-redis-prod
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --save 60 1 --loglevel warning
acai-mcp-server:
build: .
image: acai-mcp-server
container_name: acai-mcp-server-prod
restart: unless-stopped
depends_on:
- redis
ports:
- "3000:3000" # MCP SSE endpoint (prod)
- "4545:4545" # Monitor UI
volumes:
- ./:/app
- /app/node_modules
- figma_shared:/app/figma_images
command: npm run dev
env_file:
- .env
environment:
- MCP_PORT=3000
- MCP_MONITOR_PORT=4545
- FIGMA_IMAGES_DIR=/app/figma_images
- REDIS_URL=redis://redis:6379
volumes:
redis-data:
figma_shared:
external: true

View File

@@ -0,0 +1,36 @@
name: acai-code-mcp
services:
redis:
image: redis:7-alpine
container_name: acai-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --save 60 1 --loglevel warning
acai-mcp-server:
build: .
image: acai-mcp-server
container_name: acai-mcp-server
restart: unless-stopped
depends_on:
- redis
ports:
- "3010:3010" # MCP SSE endpoint
- "4545:4545" # Monitor UI
volumes:
- ./:/app
- /app/node_modules
command: npm run dev
env_file:
- .env
environment:
- MCP_PORT=3010
- MCP_MONITOR_PORT=4545
- REDIS_URL=redis://redis:6379
volumes:
redis-data:

85
mcp-server/fieldData.json Normal file
View File

@@ -0,0 +1,85 @@
{
"menu": "database",
"_defaultAction": "editTable",
"tableName": "",
"fieldname": "",
"order": 0,
"editField": 1,
"label": "",
"newFieldname": "",
"type": "",
"defaultValue": "",
"defaultContent": "",
"checkedByDefault": 0,
"descriptionjson": {},
"description": "",
"optionsTablename20": "",
"optionsValueField20": "",
"optionsLabelField20": "",
"checkedValue": 1,
"uncheckedValue": 0,
"fieldHeight": 300,
"tablaAuxiliar": 0,
"fieldWidth": null,
"tipoTags": 0,
"tipoAtributo": 0,
"allowUploads": 1,
"wysywigAvanzado": 1,
"yearRangeStart": 2010,
"yearRangeEnd": 2026,
"showTime": 1,
"use24HourFormat": 1,
"showSeconds": 1,
"listType": "pulldown",
"optionsType": "text",
"optionsText": "option one\noption two\noption three",
"optionsTablename": null,
"optionsValueField": null,
"optionsLabelField": null,
"optionsQuery": "SELECT fieldname1, fieldname2 FROM cms_tableName",
"filterField": null,
"separatorType": "blank line",
"separatorHeader": "",
"separatorHTML": "<tr><td colspan='2'></td></tr>",
"isRequired": 0,
"isUnique": 0,
"minLength": null,
"maxLength": null,
"charsetRule": "",
"charset": "",
"allowedExtensions": "gif,jpg,png,wmv,mov,swf,pdf",
"checkMaxUploads": 1,
"maxUploads": 25,
"checkMaxUploadSize": 1,
"maxUploadSizeKB": 5120,
"resizeOversizedImages": 1,
"maxImageWidth": 1024,
"maxImageHeight": 1024,
"createThumbnails": 1,
"maxThumbnailWidth": 150,
"maxThumbnailHeight": 150,
"createThumbnails2": 0,
"maxThumbnailWidth2": 150,
"maxThumbnailHeight2": 150,
"createThumbnails3": 0,
"maxThumbnailWidth3": 150,
"maxThumbnailHeight3": 150,
"createThumbnails4": 0,
"maxThumbnailWidth4": 150,
"maxThumbnailHeight4": 150,
"plUpload": 1,
"isSystemField": 0,
"adminOnly": 0,
"isPasswordField": 0,
"autoFormat": 1,
"infoField1": "",
"infoField2": "",
"infoField3": "",
"infoField4": "",
"infoField5": "",
"useCustomUploadDir": 0,
"customUploadDir": "/var/www/vhosts/ws.cocosolution.com/httpdocs/cms/uploads/",
"customUploadUrl": "/uploads/",
"customColumnType": "",
"save": 1
}

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* HTTP MCP Client Bridge
* Acts as a bridge between:
* - HTTP Server (StreamableHTTPServerTransport at /mcp endpoint)
* - Claude Code (via stdio)
*
* Usage: node http-mcp-client.js <url> [--header name:value] ...
* Example: node http-mcp-client.js http://localhost:3000/mcp --header X-Acai-Token:abc123
*/
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/http.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node http-mcp-client.js <url> [--header name:value] ...");
console.error("Example: node http-mcp-client.js http://localhost:3000/mcp --header X-Acai-Token:abc123");
process.exit(1);
}
const url = args[0];
const headers = {};
// Parse headers from command line
for (let i = 1; i < args.length; i += 2) {
if (args[i] === "--header" && i + 1 < args.length) {
const [name, value] = args[i + 1].split(":");
if (name && value) {
headers[name.trim()] = value.trim();
}
}
}
console.error(`[HTTP Bridge] Connecting to ${url}`);
console.error(`[HTTP Bridge] Headers: ${Object.keys(headers).join(", ")}`);
try {
// Create StreamableHTTP client transport to connect to our HTTP server
// This transport supports the modern HTTP/EventStream protocol (2025-03-26)
const httpTransport = new StreamableHTTPClientTransport(new URL(url), {
headers
});
// Create stdio transport for Claude Code communication
const stdioTransport = new StdioClientTransport();
// Create MCP client connected to HTTP server
const client = new Client({
name: "acai-http-bridge",
version: "1.0.0"
});
console.error("[HTTP Bridge] Connecting to HTTP server...");
await client.connect(httpTransport);
console.error("[HTTP Bridge] Connected to HTTP server successfully");
// Now bridge the client's tools to Claude Code via stdio
console.error("[HTTP Bridge] Forwarding tools to Claude Code via stdio...");
await stdioTransport.start(client);
console.error("[HTTP Bridge] Bridge active and ready");
} catch (error) {
console.error("[HTTP Bridge] ERROR:", error.message);
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
}
}
main();

788
mcp-server/httpServer.js Normal file
View File

@@ -0,0 +1,788 @@
import fs from "fs";
import path from "path";
import os from "os";
import crypto from "node:crypto";
import express from "express";
import cors from "cors";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { MCP_PORT, JWT_SECRET } from "./config/index.js";
import {
sessionCredentials,
sessionApiClients,
sessionServers,
clearSessionCredentials,
setSessionUserToken,
setMcpSessionCredentials,
getMcpSessionCredentials
} from "./auth/index.js";
import { fetchProjectInfo } from "./auth/localClient.js";
import { createSessionServer } from "./server.js";
// Active sessions - stores { transport, server, type, heartbeatInterval }
const activeSessions = new Map();
const ACCESS_TOKEN_TTL = 3600; // seconds
const base64url = (value) => Buffer.from(value).toString("base64url");
const previewToken = (value) => {
if (!value) return "<empty>";
if (value.length <= 16) return value;
return `${value.slice(0, 12)}...${value.slice(-8)} (len=${value.length})`;
};
const signJwt = (payload, expiresInSeconds = ACCESS_TOKEN_TTL) => {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const enrichedPayload = { iat: now, exp: now + expiresInSeconds, ...payload };
const headerEncoded = base64url(JSON.stringify(header));
const payloadEncoded = base64url(JSON.stringify(enrichedPayload));
const signature = crypto
.createHmac("sha256", JWT_SECRET)
.update(`${headerEncoded}.${payloadEncoded}`)
.digest("base64url");
return `${headerEncoded}.${payloadEncoded}.${signature}`;
};
const verifyJwt = (token) => {
try {
const [headerEncoded, payloadEncoded, signature] = token.split(".");
if (!headerEncoded || !payloadEncoded || !signature) {
return null;
}
const expected = crypto
.createHmac("sha256", JWT_SECRET)
.update(`${headerEncoded}.${payloadEncoded}`)
.digest("base64url");
if (expected !== signature) {
return null;
}
const payload = JSON.parse(Buffer.from(payloadEncoded, "base64url").toString());
if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) {
return null;
}
return payload;
} catch (error) {
console.error(`[MCP HTTP] JWT verification error: ${error.message}`);
return null;
}
};
const resolveProjectCredentials = async (projectName) => {
try {
const info = await fetchProjectInfo(projectName);
if (!info.success) {
throw new Error(info.error || "Failed to resolve project info");
}
return {
token: info.token,
tokenHash: info.tokenHash || null,
website: info.domain,
web_url: info.web_url,
api_web_url: info.api_web_url || info.web_url,
forge_host: info.forge_host || null,
};
} catch (error) {
throw new Error(`Failed to resolve project '${projectName}': ${error.message}`);
}
};
/**
* Configure credentials from request headers/query params for a session
*/
const configureSessionCredentials = async (sessionId, { token, tokenHash, website, web_url, userToken, projectName }) => {
// Priority 1: Resolve via project name from local Python server
if (projectName) {
try {
const projectCreds = await resolveProjectCredentials(projectName);
sessionCredentials.set(sessionId, {
token: projectCreds.token,
tokenHash: projectCreds.tokenHash || null,
website: projectCreds.website,
web_url: projectCreds.web_url,
api_web_url: projectCreds.api_web_url || projectCreds.web_url,
forge_host: projectCreds.forge_host || null,
profileName: 'project-' + projectName,
role: 'developer',
});
console.log(`[MCP] Session ${sessionId} authenticated via project '${projectName}' - web_url: ${projectCreds.web_url}`);
return true;
} catch (error) {
console.error(`[MCP] Failed to resolve project '${projectName}': ${error.message}`);
// Fall through to try other auth methods
}
}
// Priority 2: Direct credentials (legacy header-based)
if (token && website) {
sessionCredentials.set(sessionId, {
token,
tokenHash: tokenHash || null,
website,
web_url: web_url || `https://${website}`,
api_web_url: web_url || `https://${website}`,
forge_host: null,
profileName: 'http-session',
role: 'developer',
});
console.log(`[MCP] Session ${sessionId} authenticated - website: ${website}`);
return true;
} else if (userToken) {
setSessionUserToken(userToken, sessionId);
console.log(`[MCP] Session ${sessionId} with userToken for auto-login`);
return true;
}
return false;
};
/**
* Extract credentials from request (headers or query params)
*/
const extractCredentialsFromRequest = (req) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
return {
projectName: url.searchParams.get('project') || req.headers['x-project-name'],
token: url.searchParams.get('token') || req.headers['x-acai-token'],
tokenHash: url.searchParams.get('tokenHash') || req.headers['x-acai-token-hash'],
website: url.searchParams.get('website') || req.headers['x-acai-website'],
userToken: url.searchParams.get('userToken') || req.headers['x-user-token']
};
};
/**
* Create and start the MCP HTTP server with both Streamable HTTP and SSE transports
*/
export function startHttpServer() {
const app = express();
// Parse JSON and URL-encoded bodies
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Configure CORS
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'X-Acai-Token', 'X-Acai-Website', 'X-Acai-Token-Hash', 'X-User-Token', 'X-Project-Name', 'Authorization', 'Mcp-Session-Id'],
exposedHeaders: ['Mcp-Session-Id'],
credentials: true
}));
//=============================================================================
// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26)
// This is the new recommended transport for MCP
//=============================================================================
app.all('/mcp', async (req, res) => {
console.log(`[MCP Streamable] ${req.method} /mcp`);
try {
const mcpSessionId = req.headers['mcp-session-id'];
let transport;
if (mcpSessionId && activeSessions.has(mcpSessionId)) {
// Reuse existing transport
const session = activeSessions.get(mcpSessionId);
if (session.type === 'streamable') {
transport = session.transport;
console.log(`[MCP Streamable] Reusing session ${mcpSessionId.substring(0, 8)}...`);
} else {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: Session exists but uses a different transport protocol'
},
id: null
});
return;
}
} else if (!mcpSessionId && req.method === 'POST' && isInitializeRequest(req.body)) {
// New initialization request - create new transport
console.log(`[MCP Streamable] New initialization request`);
// Extract credentials from request
const credentials = extractCredentialsFromRequest(req);
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (sessionId) => {
console.log(`[MCP Streamable] Session initialized: ${sessionId.substring(0, 8)}...`);
// Store the transport
activeSessions.set(sessionId, {
transport,
server: null, // Will be set after connect
type: 'streamable',
startTime: Date.now()
});
// Configure credentials for this session (async, fire-and-forget)
configureSessionCredentials(sessionId, credentials).then((configured) => {
if (configured) {
// Also store credentials by MCP-Session-Id for persistence
const creds = sessionCredentials.get(sessionId);
if (creds) {
setMcpSessionCredentials(sessionId, creds);
}
}
}).catch((err) => {
console.error(`[MCP Streamable] Error configuring credentials for session ${sessionId}:`, err.message);
});
},
onsessionclosed: (sessionId) => {
console.log(`[MCP Streamable] Session closed: ${sessionId.substring(0, 8)}...`);
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
}
});
// Set up onclose handler
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && activeSessions.has(sid)) {
console.log(`[MCP Streamable] Transport closed for session ${sid.substring(0, 8)}...`);
activeSessions.delete(sid);
clearSessionCredentials(sid);
}
};
// Create session-specific server and connect
const sessionServer = createSessionServer();
await sessionServer.connect(transport);
// Store session server for role filtering
if (transport.sessionId) {
sessionServers.set(transport.sessionId, sessionServer);
}
// Update session with server reference
if (transport.sessionId) {
const session = activeSessions.get(transport.sessionId);
if (session) {
session.server = sessionServer;
}
}
} else if (mcpSessionId) {
// Session ID provided but session not found - try to recover credentials
const savedCreds = getMcpSessionCredentials(mcpSessionId);
if (savedCreds) {
console.log(`[MCP Streamable] Recovering credentials for session ${mcpSessionId.substring(0, 8)}...`);
// Session might have been lost due to server restart - need to re-initialize
}
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: Session not found. Please reinitialize.'
},
id: null
});
return;
} else {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided and not an initialization request'
},
id: null
});
return;
}
// Handle the request with the transport
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('[MCP Streamable] Error:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error'
},
id: null
});
}
}
});
//=============================================================================
// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05)
// Kept for backwards compatibility with older clients
//=============================================================================
// SSE connection endpoint (GET /sse)
app.get('/sse', async (req, res) => {
console.log(`[MCP SSE] New SSE connection`);
const credentials = extractCredentialsFromRequest(req);
// Create SSE transport
const transport = new SSEServerTransport("/message", res);
const sessionId = transport.sessionId;
console.log(`[MCP SSE] Session created: ${sessionId}`);
// Create session-specific server
const sessionServer = createSessionServer();
// Store session server for role filtering
sessionServers.set(sessionId, sessionServer);
// Set up heartbeat
const heartbeatInterval = setInterval(() => {
try {
if (res.writableEnded || res.destroyed) {
clearInterval(heartbeatInterval);
return;
}
res.write(': heartbeat\n\n');
} catch (err) {
console.error(`[MCP SSE] Heartbeat error for session ${sessionId}:`, err.message);
clearInterval(heartbeatInterval);
}
}, 30000);
// Configure credentials
await configureSessionCredentials(sessionId, credentials);
// Store session
activeSessions.set(sessionId, {
transport,
server: sessionServer,
type: 'sse',
heartbeatInterval,
startTime: Date.now()
});
// Handle close
res.on('close', () => {
console.log(`[MCP SSE] Connection closed for session ${sessionId}`);
});
transport.onclose = () => {
const session = activeSessions.get(sessionId);
if (!session) return;
activeSessions.delete(sessionId);
if (session.heartbeatInterval) {
clearInterval(session.heartbeatInterval);
}
clearSessionCredentials(sessionId);
console.log(`[MCP SSE] Session ${sessionId} cleaned up`);
};
try {
await sessionServer.connect(transport);
console.log(`[MCP SSE] Session ${sessionId} connected`);
} catch (error) {
console.error(`[MCP SSE] Connection error for session ${sessionId}:`, error.message);
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
}
});
// Message endpoint for SSE transport (POST /message)
app.post('/message', async (req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("sessionId");
if (!sessionId) {
res.status(400).json({ error: "Missing session ID" });
return;
}
const session = activeSessions.get(sessionId);
if (!session || session.type !== 'sse') {
res.status(404).json({ error: "Session not found" });
return;
}
const { transport, heartbeatInterval } = session;
// Check if SSE connection is still alive
const sseResponse = transport._sseResponse;
if (!sseResponse || sseResponse.writableEnded || sseResponse.destroyed) {
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
}
res.status(410).json({ error: "SSE connection closed", code: "SSE_CLOSED" });
return;
}
try {
await transport.handlePostMessage(req, res, req.body);
} catch (error) {
console.error(`[MCP SSE] POST error for session ${sessionId}:`, error.message);
if (!res.headersSent) {
res.status(500).json({ error: error.message });
}
}
});
// Root path normalization (for clients that call "/" instead of /sse or /mcp)
app.get('/', (req, res) => {
// Redirect to SSE for backwards compatibility
res.redirect('/sse');
});
app.post('/', async (req, res) => {
// Check if it's a Streamable HTTP initialize request
if (isInitializeRequest(req.body)) {
// Forward to /mcp
req.url = '/mcp';
app.handle(req, res);
} else {
// Forward to /message (needs sessionId in query)
req.url = '/message';
app.handle(req, res);
}
});
//=============================================================================
// HEALTH CHECK
//=============================================================================
app.get('/health', (req, res) => {
const sseCount = Array.from(activeSessions.values()).filter(s => s.type === 'sse').length;
const streamableCount = Array.from(activeSessions.values()).filter(s => s.type === 'streamable').length;
res.json({
status: "ok",
activeSessions: activeSessions.size,
sse: sseCount,
streamable: streamableCount,
mode: "hybrid"
});
});
//=============================================================================
// OAUTH2 ENDPOINTS
//=============================================================================
// OAuth2 Authorization Server Metadata endpoint (per RFC8414)
app.get('/.well-known/oauth-authorization-server', (req, res) => {
const baseUrl = `https://${req.headers.host}`;
res.json({
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/authorize`,
token_endpoint: `${baseUrl}/token`,
registration_endpoint: `${baseUrl}/register`,
grant_types_supported: ["authorization_code", "client_credentials"],
response_types_supported: ["code"],
token_endpoint_auth_methods_supported: ["client_secret_post", "none"],
service_documentation: `${baseUrl}/docs`,
code_challenge_methods_supported: ["S256"],
mcp_endpoint: `${baseUrl}/mcp`
});
});
// OAuth2 Dynamic Client Registration endpoint (per RFC7591)
app.post('/register', (req, res) => {
const clientInfo = req.body;
console.error(`[MCP HTTP] POST /register - Client registration request:`, clientInfo);
const clientId = `acai-${Date.now()}-${Math.random().toString(36).substring(7)}`;
res.status(201).json({
client_id: clientId,
client_id_issued_at: Math.floor(Date.now() / 1000),
redirect_uris: clientInfo.redirect_uris || [],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"]
});
console.error(`[MCP HTTP] POST /register - Registered client: ${clientId}`);
});
// OAuth2 Authorization endpoint
app.get('/authorize', (req, res) => {
const { client_id: clientId, redirect_uri: redirectUri, state, code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod = "S256" } = req.query;
console.error(`[MCP HTTP] GET /authorize - client_id: ${clientId}, redirect_uri: ${redirectUri}`);
if (!clientId || !redirectUri) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing client_id or redirect_uri"
});
return;
}
const code = Buffer.from(JSON.stringify({
client_id: clientId,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
timestamp: Date.now(),
expires_at: Date.now() + (10 * 60 * 1000)
})).toString('base64');
const callback = new URL(redirectUri);
callback.searchParams.set("code", code);
if (state) callback.searchParams.set("state", state);
console.error(`[MCP HTTP] GET /authorize - Redirecting to: ${callback.toString()}`);
res.redirect(302, callback.toString());
});
// OAuth2 Token endpoint
app.post('/token', async (req, res) => {
console.error(`[MCP HTTP] POST /token - Received token request`);
try {
let params = req.body;
// Handle form-encoded body
const contentType = req.headers['content-type'] || '';
if (contentType.includes('application/x-www-form-urlencoded') && typeof req.body === 'string') {
const searchParams = new URLSearchParams(req.body);
params = Object.fromEntries(searchParams);
}
const { grant_type, client_secret, code, code_verifier } = params;
console.error(`[MCP HTTP] POST /token - grant_type: ${grant_type}, has_code: ${!!code}, has_client_secret: ${!!client_secret}`);
if (grant_type === 'authorization_code') {
if (!code) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing authorization code"
});
return;
}
try {
const decodedCode = JSON.parse(Buffer.from(code, 'base64').toString());
if (decodedCode.expires_at && Date.now() > decodedCode.expires_at) {
res.status(400).json({
error: "invalid_grant",
error_description: "Authorization code has expired"
});
return;
}
if (decodedCode.code_challenge && code_verifier) {
const digest = crypto.createHash('sha256').update(code_verifier).digest('base64url');
if (digest !== decodedCode.code_challenge) {
res.status(400).json({
error: "invalid_grant",
error_description: "PKCE code verifier mismatch"
});
return;
}
}
// client_secret = project name to resolve via local server
if (!client_secret) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing client_secret (should be project name)"
});
return;
}
const projectCreds = await resolveProjectCredentials(client_secret);
const accessToken = signJwt({
acaiToken: projectCreds.token,
acaiTokenHash: projectCreds.tokenHash,
website: projectCreds.website,
web_url: projectCreds.web_url,
clientId: decodedCode.client_id,
tokenType: "acai-credentials",
});
res.setHeader("Cache-Control", "no-store");
res.setHeader("Pragma", "no-cache");
res.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: ACCESS_TOKEN_TTL
});
} catch (error) {
console.error(`[MCP HTTP] POST /token (auth_code) - ERROR:`, error.message);
res.status(400).json({
error: "invalid_grant",
error_description: "Invalid authorization code"
});
}
return;
}
if (grant_type !== 'client_credentials') {
res.status(400).json({
error: "unsupported_grant_type",
error_description: "Only 'client_credentials' and 'authorization_code' grant types are supported"
});
return;
}
// client_secret = project name to resolve via local server
if (!client_secret) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing client_secret (should be project name)"
});
return;
}
const projectCreds = await resolveProjectCredentials(client_secret);
const accessToken = signJwt({
acaiToken: projectCreds.token,
acaiTokenHash: projectCreds.tokenHash,
website: projectCreds.website,
web_url: projectCreds.web_url,
clientId: params.client_id || "client_credentials",
tokenType: "acai-credentials",
});
res.setHeader("Cache-Control", "no-store");
res.setHeader("Pragma", "no-cache");
res.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: ACCESS_TOKEN_TTL
});
} catch (error) {
console.error(`[MCP HTTP] POST /token - ERROR:`, error.message);
const isAuthError = error.message?.toLowerCase().includes("exchange");
res.status(isAuthError ? 400 : 500).json({
error: isAuthError ? "invalid_grant" : "server_error",
error_description: error.message
});
}
});
//=============================================================================
// STATIC FILE SERVING
//=============================================================================
// Serve Figma images
app.get('/figma-images/:fileName', (req, res) => {
const { fileName } = req.params;
if (!fileName) {
res.status(400).json({ error: "File name required" });
return;
}
try {
const figmaDir = process.env.FIGMA_IMAGES_DIR || '/app/figma_images';
const filePath = path.join(figmaDir, fileName);
const resolvedPath = path.resolve(filePath);
const resolvedFigmaDir = path.resolve(figmaDir);
if (!resolvedPath.startsWith(resolvedFigmaDir)) {
res.status(403).json({ error: "Access denied" });
return;
}
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: "File not found", path: fileName });
return;
}
const ext = path.extname(fileName).toLowerCase();
const contentTypes = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml'
};
const contentType = contentTypes[ext] || 'application/octet-stream';
const fileBuffer = fs.readFileSync(filePath);
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", fileBuffer.length);
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(fileBuffer);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Serve generated images
app.get('/generated-images/:fileId', (req, res) => {
const { fileId } = req.params;
if (!fileId) {
res.status(400).json({ error: "File ID required" });
return;
}
try {
const tempDir = process.env.GENERATED_IMAGES_TEMP_DIR || path.join(os.tmpdir(), 'generated-images');
const filename = `generated-${fileId}.jpg`;
const filePath = path.join(tempDir, filename);
const resolvedPath = path.resolve(filePath);
const resolvedTempDir = path.resolve(tempDir);
if (!resolvedPath.startsWith(resolvedTempDir)) {
res.status(403).json({ error: "Access denied" });
return;
}
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: "File not found" });
return;
}
const fileBuffer = fs.readFileSync(filePath);
res.setHeader("Content-Type", "image/jpeg");
res.setHeader("Content-Length", fileBuffer.length);
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(fileBuffer);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
//=============================================================================
// START SERVER
//=============================================================================
const server = app.listen(MCP_PORT, '0.0.0.0', () => {
console.error(`[MCP] Server listening on http://0.0.0.0:${MCP_PORT}`);
console.error(`[MCP] Streamable HTTP endpoint: /mcp (recommended)`);
console.error(`[MCP] Legacy SSE endpoint: /sse (backwards compatible)`);
console.error(`[MCP] Provide credentials via headers: X-Acai-Token, X-Acai-Website, X-Acai-Token-Hash`);
});
server.on("error", (error) => {
console.error(`[MCP] Server error:`, error);
});
// Handle shutdown
process.on('SIGINT', async () => {
console.log('[MCP] Shutting down server...');
for (const [sessionId, session] of activeSessions.entries()) {
try {
console.log(`[MCP] Closing session ${sessionId}`);
if (session.transport.close) {
await session.transport.close();
}
if (session.heartbeatInterval) {
clearInterval(session.heartbeatInterval);
}
} catch (error) {
console.error(`[MCP] Error closing session ${sessionId}:`, error);
}
}
activeSessions.clear();
console.log('[MCP] Shutdown complete');
process.exit(0);
});
return server;
}
export { activeSessions };

View File

@@ -0,0 +1,234 @@
import http from "node:http";
import fs from "fs";
import path from "path";
import os from "os";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { MCP_PORT } from "./config/index.js";
import {
sessionCredentials,
sessionApiClients,
clearSessionCredentials
} from "./auth/index.js";
// Active SSE sessions
const activeSessions = new Map();
/**
* Create and start the MCP HTTP server for SSE transport
*/
export function startHttpServer(server) {
const mcpHttpServer = http.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
// Handle SSE connection (GET /sse)
if (req.method === "GET" && url.pathname === "/sse") {
console.error(`[MCP HTTP] GET /sse - New SSE connection from ${req.headers.origin || req.socket.remoteAddress}`);
// Extract credentials from headers
const token = req.headers['x-acai-token'];
const tokenHash = req.headers['x-acai-token-hash'];
const website = req.headers['x-acai-website'];
// Create SSE transport
const transport = new SSEServerTransport("/message", res);
const sessionId = transport.sessionId;
// Wrap the send method to add logging and fix SSE format
const originalSend = transport.send.bind(transport);
transport.send = async (message) => {
try {
if (!transport._sseResponse) {
throw new Error('Not connected');
}
// Serialize message and ensure it's valid for SSE format
const messageJson = JSON.stringify(message);
console.error(`[MCP HTTP] SSE.send - Session ${sessionId} - Sending message (${messageJson.substring(0, 100)}...)`);
// SSE format: properly handle multiline data
// If JSON contains newlines, each line must be prefixed with "data: "
const lines = messageJson.split('\n');
let sseData = 'event: message\n';
if (lines.length === 1) {
// Single line JSON
sseData += `data: ${messageJson}\n\n`;
} else {
// Multiline JSON - prefix each line
for (const line of lines) {
sseData += `data: ${line}\n`;
}
sseData += '\n';
}
transport._sseResponse.write(sseData);
console.error(`[MCP HTTP] SSE.send - Session ${sessionId} - Message sent successfully (${sseData.length} bytes)`);
} catch (error) {
console.error(`[MCP HTTP] SSE.send - Session ${sessionId} - ERROR sending message:`, error.message);
throw error;
}
};
console.error(`[MCP HTTP] GET /sse - Session ${sessionId} created`);
// Store credentials for this session
if (token && website) {
sessionCredentials.set(sessionId, {
token,
tokenHash: tokenHash || null,
website,
profileName: 'http-session'
});
console.error(`[MCP HTTP] GET /sse - Session ${sessionId} authenticated for ${website}`);
} else {
console.warn(`[MCP HTTP] GET /sse - Session ${sessionId} started without credentials`);
}
// Store session
activeSessions.set(sessionId, transport);
// Handle transport close
transport.onclose = () => {
console.error(`[MCP HTTP] GET /sse - Session ${sessionId} closed`);
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
};
// Connect server to transport
try {
console.error(`[MCP HTTP] GET /sse - Session ${sessionId} connecting server to transport...`);
await server.connect(transport);
console.error(`[MCP HTTP] GET /sse - Session ${sessionId} server connected successfully`);
} catch (error) {
console.error(`[MCP HTTP] GET /sse - Session ${sessionId} ERROR:`, error);
activeSessions.delete(sessionId);
clearSessionCredentials(sessionId);
}
return;
}
// Handle POST messages (POST /message?sessionId=xxx)
if (req.method === "POST" && url.pathname === "/message") {
const sessionId = url.searchParams.get("sessionId");
if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Missing session ID" }));
return;
}
const transport = activeSessions.get(sessionId);
if (!transport) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Session not found" }));
return;
}
// Read request body
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", async () => {
try {
console.error(`[MCP HTTP] POST /message - Session ${sessionId} - Received request, parsing...`);
const parsedBody = JSON.parse(body);
console.error(`[MCP HTTP] POST /message - Session ${sessionId} - Parsed body: ${JSON.stringify(parsedBody).substring(0, 100)}...`);
// Track when handlePostMessage starts and ends
const beforeTime = Date.now();
await transport.handlePostMessage(req, res, parsedBody);
const afterTime = Date.now();
console.error(`[MCP HTTP] POST /message - Session ${sessionId} - handlePostMessage completed (took ${afterTime - beforeTime}ms)`);
console.error(`[MCP HTTP] POST /message - Session ${sessionId} - Response headersSent: ${res.headersSent}, writableEnded: ${res.writableEnded}`);
} catch (error) {
console.error(`[MCP HTTP] POST /message - Session ${sessionId} - ERROR:`, error.message);
console.error(`[MCP HTTP] POST /message - Session ${sessionId} - Stack:`, error.stack);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: error.message }));
}
}
});
return;
}
// Health check
if (req.method === "GET" && url.pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
status: "ok",
activeSessions: activeSessions.size,
mode: "sse"
}));
return;
}
// Serve generated images from temp folder
if (req.method === "GET" && url.pathname.startsWith("/generated-images/")) {
const fileId = url.pathname.split("/generated-images/")[1];
if (!fileId) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "File ID required" }));
return;
}
try {
const tempDir = process.env.GENERATED_IMAGES_TEMP_DIR || path.join(os.tmpdir(), 'generated-images');
const filename = `generated-${fileId}.jpg`;
const filePath = path.join(tempDir, filename);
// Security: ensure file is within temp directory (prevent path traversal)
const resolvedPath = path.resolve(filePath);
const resolvedTempDir = path.resolve(tempDir);
if (!resolvedPath.startsWith(resolvedTempDir)) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Access denied" }));
return;
}
// Check if file exists
if (!fs.existsSync(filePath)) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "File not found" }));
return;
}
// Read and serve file
const fileBuffer = fs.readFileSync(filePath);
res.writeHead(200, {
"Content-Type": "image/jpeg",
"Content-Length": fileBuffer.length,
"Cache-Control": "public, max-age=3600"
});
res.end(fileBuffer);
return;
} catch (error) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: error.message }));
return;
}
}
// 404 for other routes
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
});
mcpHttpServer.on("error", (error) => {
console.error(`[MCP] HTTP server error:`, error);
});
mcpHttpServer.listen(MCP_PORT, () => {
console.error(`[MCP] SSE server listening on http://localhost:${MCP_PORT}/sse`);
console.error(`[MCP] Clients should connect to: http://localhost:${MCP_PORT}/sse`);
console.error(`[MCP] Provide credentials via headers: X-Acai-Token, X-Acai-Website, X-Acai-Token-Hash`);
});
return mcpHttpServer;
}
export { activeSessions };

48
mcp-server/index.js Normal file
View File

@@ -0,0 +1,48 @@
/**
* Acai Code MCP Server - Entry Point
*
* This is the main entry point for the MCP server.
* All functionality is modularized in separate files for better maintainability.
*/
// Load configuration first
import { loadLocalConfigProfile, applyProfileToEnv } from "./config/index.js";
// Load and apply config profile (backward compatibility)
const selectedProfile = loadLocalConfigProfile();
applyProfileToEnv(selectedProfile);
console.error("[MCP] Server starting in SSE mode. Credentials will be provided per-session via HTTP headers.");
// Import core modules
import { createMcpServer, createRequestMonitor, toolHandlers, setRegistrationFunctions } from "./server.js";
import { startHttpServer } from "./httpServer.js";
import { startMonitorServer } from "./monitor.js";
// Import registration functions
import { registerPrompts } from "./prompts/index.js";
import { registerTools } from "./tools/index.js";
import { registerResources } from "./resources/index.js";
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// IMPORTANT: Set registration functions BEFORE starting HTTP server
// Each session creates its own server instance with these functions
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
setRegistrationFunctions({ registerPrompts, registerTools, registerResources });
// Create the shared request monitor (will be applied to each session server)
const requestMonitor = createRequestMonitor();
// Create a server instance for retry functionality in the monitor UI
const server = createMcpServer();
registerPrompts(server);
registerTools(server);
registerResources(server);
// Start HTTP server for SSE transport
// Each session will create its own server instance via createSessionServer()
startHttpServer();
// Start monitor server (if not disabled)
startMonitorServer(requestMonitor, toolHandlers);

44
mcp-server/index.new.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* Acai Code MCP Server - Entry Point
*
* This is the main entry point for the MCP server.
* All functionality is modularized in separate files for better maintainability.
*/
// Load configuration first
import { loadLocalConfigProfile, applyProfileToEnv } from "./config/index.js";
// Load and apply config profile (backward compatibility)
const selectedProfile = loadLocalConfigProfile();
applyProfileToEnv(selectedProfile);
console.error("[MCP] Server starting in SSE mode. Credentials will be provided per-session via HTTP headers.");
// Import core modules
import { createMcpServer, createRequestMonitor, toolHandlers } from "./server.js";
import { startHttpServer } from "./httpServer.js";
import { startMonitorServer } from "./monitor.js";
// Import registration functions
import { registerPrompts } from "./prompts/index.js";
import { registerTools } from "./tools/index.js";
// Create and configure MCP server
const server = createMcpServer();
// Create request monitor
const requestMonitor = createRequestMonitor(server);
// Register all prompts
registerPrompts(server);
// Register all tools
registerTools(server);
// Start HTTP server for SSE transport
startHttpServer(server);
// Start monitor server (if not disabled)
startMonitorServer(requestMonitor, toolHandlers);

917
mcp-server/monitor.html Normal file
View File

@@ -0,0 +1,917 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Monitor MCP</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, "SF Pro Display", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #0f172a;
color: #e2e8f0;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background: #0f172a;
}
header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #1e293b;
display: flex;
justify-content: space-between;
align-items: center;
}
main {
flex: 1;
display: grid;
grid-template-columns: minmax(260px, 360px) 1fr;
gap: 1px;
background: #1e293b;
}
section {
background: #0f172a;
overflow: hidden;
}
.list {
display: flex;
flex-direction: column;
}
.list-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid #1e293b;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #94a3b8;
}
.request-list {
flex: 1;
overflow-y: auto;
}
.request-item {
padding: 0.85rem 1rem;
border-bottom: 1px solid #1e293b;
cursor: pointer;
transition: background 0.2s ease;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.request-item:hover {
background: rgba(59, 130, 246, 0.08);
}
.request-item.active {
background: rgba(59, 130, 246, 0.15);
border-left: 3px solid #3b82f6;
padding-left: calc(1rem - 3px);
}
.request-top {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.request-meta {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #94a3b8;
}
.status {
padding: 0.1rem 0.45rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status.pending {
background: rgba(251, 191, 36, 0.15);
color: #facc15;
}
.status.success {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.status.error {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.details {
display: flex;
flex-direction: column;
}
.details-header {
border-bottom: 1px solid #1e293b;
padding: 0.75rem 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.details-content {
flex: 1;
overflow: auto;
padding: 1.25rem;
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 1rem;
}
.detail-card {
background: rgba(15, 23, 42, 0.6);
border: 1px solid #1e293b;
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 1rem;
}
.detail-card h2 {
margin: 0 0 0.75rem;
font-size: 1rem;
color: #f8fafc;
}
pre {
margin: 0;
padding: 0.75rem;
background: #020617;
border-radius: 0.5rem;
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.4;
border: 1px solid #1e293b;
}
code {
font-family: "JetBrains Mono", "Fira Code", Consolas, monospace;
}
.pill {
padding: 0.2rem 0.65rem;
border-radius: 999px;
font-size: 0.75rem;
background: rgba(148, 163, 184, 0.2);
color: #cbd5f5;
}
.connection-status {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
}
.dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: #22c55e;
}
.dot.offline {
background: #ef4444;
}
/* Tabs */
.tabs {
display: flex;
gap: 0.5rem;
margin-left: 2rem;
}
.tab {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #94a3b8;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.tab:hover {
background: rgba(59, 130, 246, 0.1);
border-color: #3b82f6;
}
.tab.active {
background: rgba(59, 130, 246, 0.2);
border-color: #3b82f6;
color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: flex;
flex: 1;
}
/* Sessions list */
.session-item {
padding: 1rem;
border-bottom: 1px solid #1e293b;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.session-item:hover {
background: rgba(59, 130, 246, 0.05);
}
.session-website {
font-weight: 600;
color: #f8fafc;
font-size: 1rem;
}
.session-website.no-website {
color: #94a3b8;
font-style: italic;
}
.session-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.8rem;
color: #94a3b8;
}
.session-id {
font-family: "JetBrains Mono", monospace;
background: rgba(148, 163, 184, 0.1);
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
}
.session-status {
display: flex;
align-items: center;
gap: 0.35rem;
}
.session-status .dot {
width: 0.5rem;
height: 0.5rem;
}
.sessions-summary {
padding: 0.75rem 1rem;
border-bottom: 1px solid #1e293b;
background: rgba(59, 130, 246, 0.05);
font-size: 0.85rem;
color: #94a3b8;
}
/* Stats tab */
.stats-session-card {
border: 1px solid #1e293b;
border-radius: 0.75rem;
margin: 1rem;
overflow: hidden;
}
.stats-session-header {
padding: 1rem;
background: rgba(59, 130, 246, 0.08);
border-bottom: 1px solid #1e293b;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.stats-session-header h3 {
margin: 0;
font-size: 1rem;
color: #f8fafc;
}
.stats-totals {
display: flex;
gap: 1.5rem;
font-size: 0.85rem;
color: #94a3b8;
}
.stats-totals .stat-value {
color: #e2e8f0;
font-weight: 600;
}
.tool-breakdown-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.tool-breakdown-table th {
text-align: left;
padding: 0.6rem 1rem;
color: #94a3b8;
font-weight: 500;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
border-bottom: 1px solid #1e293b;
}
.tool-breakdown-table td {
padding: 0.5rem 1rem;
border-bottom: 1px solid rgba(30, 41, 59, 0.5);
color: #e2e8f0;
}
.tool-breakdown-table tr:hover {
background: rgba(59, 130, 246, 0.05);
}
.chars-bar {
display: inline-block;
height: 6px;
border-radius: 3px;
background: #3b82f6;
min-width: 2px;
vertical-align: middle;
margin-right: 0.4rem;
}
.chars-bar.out {
background: #22c55e;
}
@media (max-width: 900px) {
main {
grid-template-columns: 1fr;
}
.details {
min-height: 50vh;
}
.tabs {
margin-left: 0;
margin-top: 0.5rem;
}
header {
flex-wrap: wrap;
}
}
</style>
</head>
<body>
<header>
<div style="display: flex; align-items: center;">
<div>
<h1 style="margin: 0; font-size: 1.25rem;">Monitor MCP</h1>
<p style="margin: 0; font-size: 0.85rem; color: #94a3b8;">Seguimiento en tiempo real</p>
</div>
<div class="tabs">
<button class="tab active" data-tab="requests" onclick="switchTab('requests')">Peticiones</button>
<button class="tab" data-tab="sessions" onclick="switchTab('sessions')">Sesiones <span id="sessionsCount" style="opacity:0.7">(0)</span></button>
<button class="tab" data-tab="stats" onclick="switchTab('stats')">Stats</button>
</div>
</div>
<div class="connection-status">
<span class="dot" id="conn-dot"></span>
<span id="conn-label">Conectando…</span>
</div>
</header>
<main>
<!-- Tab: Peticiones -->
<div class="tab-content active" id="tab-requests" style="display: contents;">
<section class="list">
<div class="list-header">Peticiones recientes</div>
<div class="request-list" id="requestList"></div>
</section>
<section class="details">
<div class="details-header">
<div>
<strong id="detailMethod"></strong>
<span class="pill" id="detailStatus">Sin selección</span>
</div>
<div style="font-size: 0.85rem; color: #94a3b8;" id="detailTimestamp"></div>
</div>
<div class="details-content" id="detailContent">
<div class="empty-state">Elige una petición para ver el payload y la respuesta.</div>
</div>
</section>
</div>
<!-- Tab: Sesiones -->
<div class="tab-content" id="tab-sessions" style="display: none; grid-column: 1 / -1;">
<section style="width: 100%; display: flex; flex-direction: column;">
<div class="sessions-summary" id="sessionsSummary">
Cargando sesiones...
</div>
<div class="request-list" id="sessionsList" style="flex: 1;"></div>
</section>
</div>
<!-- Tab: Stats -->
<div class="tab-content" id="tab-stats" style="display: none; grid-column: 1 / -1;">
<section style="width: 100%; display: flex; flex-direction: column; overflow-y: auto;">
<div class="sessions-summary" id="statsSummary">
Cargando estadisticas...
</div>
<div id="statsContent" style="flex: 1;"></div>
</section>
</div>
</main>
<script>
const requestListEl = document.getElementById("requestList");
const detailMethodEl = document.getElementById("detailMethod");
const detailStatusEl = document.getElementById("detailStatus");
const detailTimestampEl = document.getElementById("detailTimestamp");
const detailContentEl = document.getElementById("detailContent");
const connDot = document.getElementById("conn-dot");
const connLabel = document.getElementById("conn-label");
const sessionsListEl = document.getElementById("sessionsList");
const sessionsSummaryEl = document.getElementById("sessionsSummary");
const sessionsCountEl = document.getElementById("sessionsCount");
const summaries = new Map();
let sessions = [];
let sessionStats = [];
let selectedId = null;
let currentTab = "requests";
let filterSessionId = null;
function formatChars(chars) {
if (chars == null || chars === 0) return "0";
if (chars < 1000) return `${chars}`;
if (chars < 1000000) return `${(chars / 1000).toFixed(1)}K`;
return `${(chars / 1000000).toFixed(2)}M`;
}
function estimateTokens(chars) {
if (chars == null || chars === 0) return 0;
return Math.ceil(chars / 4);
}
function formatTokens(chars) {
const tokens = estimateTokens(chars);
if (tokens < 1000) return `~${tokens}`;
if (tokens < 1000000) return `~${(tokens / 1000).toFixed(1)}K`;
return `~${(tokens / 1000000).toFixed(2)}M`;
}
// Tab switching
function switchTab(tabName) {
currentTab = tabName;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelector(`.tab[data-tab="${tabName}"]`).classList.add('active');
document.getElementById('tab-requests').style.display = tabName === 'requests' ? 'contents' : 'none';
document.getElementById('tab-sessions').style.display = tabName === 'sessions' ? 'flex' : 'none';
document.getElementById('tab-stats').style.display = tabName === 'stats' ? 'flex' : 'none';
if (tabName === 'sessions') renderSessions();
if (tabName === 'stats') renderStats();
}
function formatDate(value) {
if (!value) return "—";
return new Date(value).toLocaleString();
}
function formatDuration(ms) {
if (ms == null) return "—";
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(2)} s`;
}
function setConnectionStatus(online) {
connDot.classList.toggle("offline", !online);
connLabel.textContent = online ? "Tiempo real conectado" : "Conexión perdida";
}
function formatDurationLong(ms) {
if (ms == null) return "—";
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}
function renderSessions() {
sessionsCountEl.textContent = `(${sessions.length})`;
if (!sessions.length) {
sessionsSummaryEl.textContent = "No hay sesiones activas";
sessionsListEl.innerHTML = '<div class="empty-state" style="padding: 2rem;">Sin sesiones activas.</div>';
return;
}
// Group by website
const byWebsite = {};
sessions.forEach(s => {
const key = s.website || '(sin website)';
if (!byWebsite[key]) byWebsite[key] = [];
byWebsite[key].push(s);
});
const websiteCount = Object.keys(byWebsite).length;
sessionsSummaryEl.textContent = `${sessions.length} sesión${sessions.length !== 1 ? 'es' : ''} activa${sessions.length !== 1 ? 's' : ''} en ${websiteCount} website${websiteCount !== 1 ? 's' : ''}`;
sessionsListEl.innerHTML = "";
sessions.forEach((session) => {
const div = document.createElement("div");
div.className = "session-item";
const websiteClass = session.website ? '' : 'no-website';
const websiteDisplay = session.website || '(sin credenciales)';
div.style.cursor = "pointer";
div.addEventListener("click", () => filterBySession(session.sessionId));
div.innerHTML = `
<div class="session-website ${websiteClass}">${websiteDisplay} <span style="font-size:0.75rem;font-weight:400;color:#94a3b8;">click to see requests</span></div>
<div class="session-meta">
<span class="session-status">
<span class="dot" style="background: ${session.hasToken ? '#22c55e' : '#f59e0b'}"></span>
${session.hasToken ? 'Autenticado' : 'Pendiente auth'}
</span>
<span>Duración: ${formatDurationLong(session.durationMs)}</span>
${session.profileName ? `<span>Perfil: ${session.profileName}</span>` : ''}
</div>
<div class="session-meta">
<span class="session-id">${session.sessionId}</span>
<span>Iniciada: ${formatDate(session.startTime)}</span>
</div>
`;
sessionsListEl.appendChild(div);
});
}
function renderStats() {
const statsSummaryEl = document.getElementById("statsSummary");
const statsContentEl = document.getElementById("statsContent");
if (!sessionStats.length) {
statsSummaryEl.textContent = "No hay estadisticas disponibles";
statsContentEl.innerHTML = '<div class="empty-state" style="padding: 2rem;">Sin datos de herramientas todavia.</div>';
return;
}
let totalTools = 0, totalReqChars = 0, totalResChars = 0;
sessionStats.forEach(s => {
totalTools += s.totalToolCalls;
totalReqChars += s.totalRequestChars;
totalResChars += s.totalResponseChars;
});
statsSummaryEl.textContent = `${sessionStats.length} sesion${sessionStats.length !== 1 ? 'es' : ''} | ${totalTools} tool calls | In: ${formatTokens(totalReqChars)} tokens (${formatChars(totalReqChars)} chars) | Out: ${formatTokens(totalResChars)} tokens (${formatChars(totalResChars)} chars)`;
statsContentEl.innerHTML = "";
let maxChars = 0;
sessionStats.forEach(s => {
Object.values(s.toolBreakdown).forEach(tb => {
const total = tb.requestChars + tb.responseChars;
if (total > maxChars) maxChars = total;
});
});
sessionStats.forEach(stat => {
const card = document.createElement("div");
card.className = "stats-session-card";
const sessionInfo = sessions.find(s => s.sessionId === stat.sessionId);
const websiteName = sessionInfo?.website || stat.sessionId.substring(0, 16) + "...";
const toolEntries = Object.entries(stat.toolBreakdown).sort((a, b) => (b[1].responseChars + b[1].requestChars) - (a[1].responseChars + a[1].requestChars));
let toolRowsHtml = "";
toolEntries.forEach(([name, tb]) => {
const reqBarWidth = maxChars > 0 ? Math.max(2, (tb.requestChars / maxChars) * 120) : 2;
const resBarWidth = maxChars > 0 ? Math.max(2, (tb.responseChars / maxChars) * 120) : 2;
const totalToolChars = tb.requestChars + tb.responseChars;
toolRowsHtml += `<tr>
<td style="font-family: 'JetBrains Mono', monospace; font-size: 0.8rem;">${name}</td>
<td style="text-align:center;">${tb.count}</td>
<td><span class="chars-bar" style="width:${reqBarWidth}px"></span>${formatChars(tb.requestChars)}</td>
<td><span class="chars-bar out" style="width:${resBarWidth}px"></span>${formatChars(tb.responseChars)}</td>
<td style="text-align:center;color:#a78bfa;font-weight:600;">${formatTokens(totalToolChars)}</td>
<td style="text-align:center;">${formatDuration(tb.avgDurationMs)}</td>
<td style="text-align:center; color: ${tb.errors > 0 ? '#f87171' : '#4ade80'};">${tb.errors}</td>
</tr>`;
});
card.style.cursor = "pointer";
card.addEventListener("click", () => filterBySession(stat.sessionId));
card.innerHTML = `
<div class="stats-session-header">
<h3>${websiteName} <span style="font-size:0.75rem;font-weight:400;color:#94a3b8;">click to see requests</span></h3>
<div class="stats-totals">
<span>Requests: <span class="stat-value">${stat.totalRequests}</span></span>
<span>Tool calls: <span class="stat-value">${stat.totalToolCalls}</span></span>
<span>In: <span class="stat-value">${formatTokens(stat.totalRequestChars)} tok</span> <span style="opacity:0.6">(${formatChars(stat.totalRequestChars)} chars)</span></span>
<span>Out: <span class="stat-value">${formatTokens(stat.totalResponseChars)} tok</span> <span style="opacity:0.6">(${formatChars(stat.totalResponseChars)} chars)</span></span>
<span>Errors: <span class="stat-value" style="color: ${stat.errorCount > 0 ? '#f87171' : '#4ade80'};">${stat.errorCount}</span></span>
</div>
</div>
${toolEntries.length > 0 ? `
<table class="tool-breakdown-table">
<thead><tr>
<th>Tool</th>
<th style="text-align:center;">Calls</th>
<th>Chars In</th>
<th>Chars Out</th>
<th style="text-align:center;">Est. Tokens</th>
<th style="text-align:center;">Avg Time</th>
<th style="text-align:center;">Errors</th>
</tr></thead>
<tbody>${toolRowsHtml}</tbody>
</table>` : '<div style="padding:1rem;color:#94a3b8;">No tool calls in this session.</div>'}
`;
statsContentEl.appendChild(card);
});
}
function filterBySession(sessionId) {
filterSessionId = sessionId;
selectedId = null;
detailMethodEl.textContent = "—";
detailStatusEl.textContent = "Sin selección";
detailStatusEl.className = "pill";
detailTimestampEl.textContent = "";
detailContentEl.innerHTML = '<div class="empty-state">Elige una petición para ver el payload y la respuesta.</div>';
switchTab('requests');
renderList();
}
function clearFilter() {
filterSessionId = null;
renderList();
}
function renderList() {
let items = Array.from(summaries.values()).sort((a, b) => {
return new Date(b.timestamp) - new Date(a.timestamp);
});
if (filterSessionId) {
items = items.filter(i => i.sessionId === filterSessionId);
}
// Update list header with filter info
const listHeaderEl = document.querySelector('.list-header');
if (filterSessionId) {
const sessionInfo = sessions.find(s => s.sessionId === filterSessionId);
const label = sessionInfo?.website || filterSessionId.substring(0, 12) + '...';
// Compute session-level stats for the filter banner
const stat = sessionStats.find(s => s.sessionId === filterSessionId);
const statsLine = stat ? ` | ${stat.totalToolCalls} calls | ${formatTokens(stat.totalRequestChars + stat.totalResponseChars)} tokens` : '';
listHeaderEl.innerHTML = `<span style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
<span style="color:#3b82f6;cursor:pointer;" onclick="clearFilter()">&#x2190; Todas</span>
<span>${label}${statsLine}</span>
<span style="opacity:0.6;">(${items.length} req)</span>
</span>`;
} else {
listHeaderEl.textContent = 'Peticiones recientes';
}
if (!items.length) {
requestListEl.innerHTML = '<div class="empty-state" style="padding: 2rem;">Sin peticiones todavía.</div>';
return;
}
requestListEl.innerHTML = "";
items.forEach((item) => {
const div = document.createElement("div");
div.className = "request-item";
if (item.id === selectedId) {
div.classList.add("active");
}
const toolLabel = item.toolName ? ` <span style="color:#94a3b8;font-weight:400;font-size:0.85rem;">${item.toolName}</span>` : "";
const charsLabel = item.responseChars ? ` · ${formatChars(item.responseChars)} chars` : "";
div.innerHTML = `
<div class="request-top">
<span>${item.method}${toolLabel}</span>
<span class="status ${item.status}">${item.status}</span>
</div>
<div class="request-meta">
<span>${formatDate(item.timestamp)}</span>
<span>${formatDuration(item.durationMs)}${charsLabel}</span>
</div>
${item.errorMessage ? `<div style="color:#f87171;font-size:0.8rem;">${item.errorMessage}</div>` : ""}
`;
div.addEventListener("click", () => selectRequest(item.id));
requestListEl.appendChild(div);
});
}
async function selectRequest(id) {
selectedId = id;
renderList();
detailContentEl.innerHTML = '<div class="empty-state" style="justify-content:flex-start;align-items:flex-start;">Cargando detalle…</div>';
try {
const response = await fetch(`/requests/${id}`);
if (!response.ok) {
throw new Error("No se pudo cargar el detalle");
}
const detail = await response.json();
renderDetails(detail);
} catch (error) {
detailContentEl.innerHTML = `<div class="empty-state" style="color:#f87171;">${error.message}</div>`;
}
}
function renderDetails(detail) {
detailMethodEl.textContent = detail.method;
detailStatusEl.textContent = detail.status.toUpperCase();
detailStatusEl.className = `pill status ${detail.status}`;
detailTimestampEl.textContent = `${formatDate(detail.timestamp)} · ${formatDuration(detail.durationMs)}`;
const requestPayload = syntaxHighlight(detail.request);
const responsePayload = syntaxHighlight(detail.response ?? detail.error);
const isToolCall = detail.method === "tools/call";
const retryButton = isToolCall
? `<button onclick="retryRequest('${detail.id}')" style="margin-left:auto; padding:0.3rem 0.8rem; background:#3b82f6; border:none; border-radius:4px; color:white; cursor:pointer; font-size:0.85rem;">Reenviar</button>`
: "";
// Inject button into header
const headerActions = document.createElement('div');
headerActions.style.display = 'flex';
headerActions.style.alignItems = 'center';
headerActions.style.gap = '1rem';
headerActions.innerHTML = retryButton;
// Clear previous actions if any (hacky but works for this simple UI)
// We'll just append it to detail-header
detailContentEl.innerHTML = `
<div class="detail-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem;">
<h2 style="margin:0;">Contexto</h2>
${retryButton}
</div>
<div style="font-size:0.85rem; color:#94a3b8; display:flex; flex-wrap:wrap; gap:1rem;">
<span>Session ID: <code>${detail.sessionId ?? "—"}</code></span>
${detail.toolName ? `<span>Tool: <code>${detail.toolName}</code></span>` : ""}
<span>Chars in: <code>${formatChars(detail.requestChars || 0)}</code></span>
<span>Chars out: <code>${formatChars(detail.responseChars || 0)}</code></span>
<span style="color:#a78bfa;font-weight:600;">Est. tokens: <code>${formatTokens((detail.requestChars || 0) + (detail.responseChars || 0))}</code></span>
</div>
</div>
<div class="detail-card">
<h2>Payload recibido</h2>
<pre><code>${requestPayload}</code></pre>
</div>
<div class="detail-card">
<h2>${detail.status === "error" ? "Error devuelto" : "Respuesta enviada"}</h2>
<pre><code>${responsePayload}</code></pre>
</div>
`;
}
async function retryRequest(id) {
if (!confirm("¿Estás seguro de que quieres reenviar esta petición?")) return;
try {
const btn = document.querySelector(`button[onclick="retryRequest('${id}')"]`);
if (btn) {
btn.disabled = true;
btn.textContent = "Reenviando...";
}
const response = await fetch(`/retry/${id}`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
alert("Petición reenviada con éxito. Se ha generado una nueva entrada en el monitor.");
// The SSE will update the list automatically
} else {
alert("Error al reenviar: " + (result.error || "Error desconocido"));
}
} catch (error) {
alert("Error de red: " + error.message);
} finally {
const btn = document.querySelector(`button[onclick="retryRequest('${id}')"]`);
if (btn) {
btn.disabled = false;
btn.textContent = "Reenviar";
}
}
}
function syntaxHighlight(data) {
if (data == null) {
return "—";
}
const json = JSON.stringify(data, null, 2);
return json.replace(/(&|<|>)/g, (char) => {
const map = { "&": "&amp;", "<": "&lt;", ">": "&gt;" };
return map[char];
});
}
async function bootstrap() {
const [requestsRes, sessionsRes, statsRes] = await Promise.all([
fetch("/requests"),
fetch("/sessions"),
fetch("/stats")
]);
if (requestsRes.ok) {
const { requests } = await requestsRes.json();
requests.forEach((item) => summaries.set(item.id, item));
renderList();
}
if (sessionsRes.ok) {
const data = await sessionsRes.json();
sessions = data.sessions || [];
renderSessions();
}
if (statsRes.ok) {
const data = await statsRes.json();
sessionStats = data.stats || [];
}
}
function initSSE() {
const source = new EventSource("/events");
source.addEventListener("open", () => setConnectionStatus(true));
source.addEventListener("error", () => setConnectionStatus(false));
source.addEventListener("bootstrap", (event) => {
const data = JSON.parse(event.data);
(data.requests || []).forEach((item) => summaries.set(item.id, item));
sessions = data.sessions || [];
sessionStats = data.stats || [];
renderList();
renderSessions();
if (currentTab === 'stats') renderStats();
});
source.addEventListener("summary", (event) => {
const data = JSON.parse(event.data);
summaries.set(data.id, data);
renderList();
if (selectedId === data.id) {
selectRequest(data.id);
}
});
source.addEventListener("sessions", (event) => {
const data = JSON.parse(event.data);
sessions = data.sessions || [];
renderSessions();
});
source.addEventListener("stats", (event) => {
const data = JSON.parse(event.data);
sessionStats = data.stats || [];
if (currentTab === 'stats') renderStats();
});
}
bootstrap();
initSSE();
</script>
</body>
</html>

224
mcp-server/monitor.js Normal file
View File

@@ -0,0 +1,224 @@
import http from "node:http";
import fsPromises from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { MONITOR_PORT, MONITOR_DISABLED } from "./config/index.js";
import { sessionCredentials } from "./auth/credentials.js";
import { activeSessions } from "./httpServer.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const monitorHtmlPath = path.join(__dirname, "monitor.html");
/**
* Get active sessions with their credentials info
*/
function getActiveSessions() {
const sessions = [];
for (const [sessionId, sessionData] of activeSessions.entries()) {
const creds = sessionCredentials.get(sessionId);
sessions.push({
sessionId,
website: creds?.website || null,
hasToken: !!creds?.token,
hasTokenHash: !!creds?.tokenHash,
profileName: creds?.profileName || null,
startTime: sessionData.startTime,
durationMs: Date.now() - sessionData.startTime
});
}
return sessions.sort((a, b) => b.startTime - a.startTime);
}
// SSE clients for real-time updates
export const sseClients = new Set();
/**
* Get the monitor HTML page
*/
async function getMonitorHtml() {
try {
return await fsPromises.readFile(monitorHtmlPath, "utf-8");
} catch (error) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>MCP Monitor</title>
<style>
body { font-family: sans-serif; background: #111827; color: #e5e7eb; margin: 0; padding: 2rem; }
</style>
</head>
<body>
<h1>MCP Monitor</h1>
<p>No se encontró el archivo de interfaz en <code>${monitorHtmlPath}</code>.</p>
</body>
</html>`;
}
}
/**
* Broadcast an SSE event to all connected clients
*/
export function broadcastSse(event, payload) {
const chunk = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of sseClients) {
try {
client.write(chunk);
} catch {
sseClients.delete(client);
}
}
}
/**
* Broadcast sessions update to all connected monitor clients
*/
export function broadcastSessionsUpdate() {
broadcastSse("sessions", { sessions: getActiveSessions() });
}
/**
* Start the monitor HTTP server
*/
export function startMonitorServer(requestMonitor, toolHandlers) {
if (MONITOR_DISABLED) {
console.error("MCP monitor UI deshabilitada (MCP_MONITOR_DISABLED=1).");
return null;
}
const monitorServer = http.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/monitor")) {
const html = await getMonitorHtml();
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
return;
}
if (req.method === "GET" && url.pathname === "/requests") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ requests: requestMonitor.getSummaries() }));
return;
}
if (req.method === "GET" && url.pathname.startsWith("/requests/")) {
const [, , rawId] = url.pathname.split("/");
const entry = requestMonitor.getEntryById(rawId);
if (!entry) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Request not found" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(entry));
return;
}
if (req.method === "GET" && url.pathname === "/sessions") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ sessions: getActiveSessions() }));
return;
}
if (req.method === "GET" && url.pathname === "/stats") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ stats: requestMonitor.getSessionStats() }));
return;
}
if (req.method === "GET" && url.pathname === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
});
res.write("event: bootstrap\n");
res.write(`data: ${JSON.stringify({
requests: requestMonitor.getSummaries(),
sessions: getActiveSessions(),
stats: requestMonitor.getSessionStats()
})}\n\n`);
sseClients.add(res);
req.on("close", () => {
sseClients.delete(res);
});
return;
}
if (req.method === "POST" && url.pathname.startsWith("/retry/")) {
const [, , rawId] = url.pathname.split("/");
const entry = requestMonitor.getEntryById(rawId);
if (!entry) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Request not found" }));
return;
}
if (entry.method !== "tools/call") {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Only tool calls can be retried" }));
return;
}
const toolName = entry.request.params.name;
const toolHandler = toolHandlers.get(toolName);
if (!toolHandler) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `Tool handler for '${toolName}' not found` }));
return;
}
try {
const extra = { sessionId: entry.sessionId };
const retryEntry = requestMonitor.start(entry.method, entry.request, extra);
const result = await toolHandler.handler(entry.request.params.arguments, extra);
requestMonitor.finish(retryEntry, result);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true, newRequestId: retryEntry.id, result }));
} catch (error) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: error.message }));
}
return;
}
if (req.method === "GET" && url.pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
});
monitorServer.on("error", (error) => {
console.warn(
`[monitor] No se pudo iniciar la UI en el puerto ${MONITOR_PORT}: ${error.message}. Establece MCP_MONITOR_DISABLED=1 para ocultar este aviso.`
);
});
monitorServer.listen(MONITOR_PORT, '0.0.0.0', () => {
console.error(`MCP monitor UI: http://0.0.0.0:${MONITOR_PORT}/monitor`);
});
// Broadcast sessions + stats update every 2 seconds for real-time monitoring
setInterval(() => {
if (sseClients.size > 0) {
broadcastSessionsUpdate();
broadcastSse("stats", { stats: requestMonitor.getSessionStats() });
}
}, 2000);
return monitorServer;
}

3124
mcp-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
mcp-server/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "acai-code-mcp-server",
"version": "1.0.0",
"description": "MCP Server for Acai Code",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node cluster.js",
"start:single": "node index.js",
"dev": "nodemon index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0",
"@playwright/mcp": "^0.0.68",
"axios": "^1.6.0",
"cheerio": "^1.1.2",
"cors": "^2.8.6",
"dotenv": "^16.3.1",
"express": "^5.2.1",
"jsdom": "^27.2.0",
"redis": "^4.7.0",
"sharp": "^0.33.5",
"zod": "^3.22.4"
},
"devDependencies": {
"nodemon": "^3.1.11"
}
}

View File

@@ -0,0 +1,81 @@
/**
* Prompt: guia-campos-tablas
* Referencia de tipos de campos para crear/editar tablas y sus campos en la base de datos (MySQL/PHP)
*/
export const guiaCamposTablasPrompt = {
name: "guia-campos-tablas",
description: "Referencia de tipos de campos para crear/editar tablas y sus campos en la base de datos (MySQL/PHP)",
args: {},
handler: () => {
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Al modificar el esquema de la base de datos (crear/editar tablas), utiliza estos tipos de campos.
Presta especial atención a la configuración del campo 'list'.
### Campo LIST (Selector/Relación)
El tipo **'list'** es el más versátil. Define el origen de datos con \`optionsType\`:
1. **Lista Estática (optionsType: 'text')**
- Para opciones fijas simples.
- Formato: \`Valor|Etiqueta\` (una por línea).
- Ejemplo: \`optionsText: "1|Activo\\n0|Inactivo"\`
2. **Relación con Tabla (optionsType: 'table')**
- Para relacionar con otra tabla existente (Foreign Key lógica).
- \`optionsTablename\`: Nombre de la tabla origen (ej: 'cms_categorias').
- \`optionsValueField\`: Campo que se guardará como valor (ej: 'id').
- \`optionsLabelField\`: Campo que se mostrará al usuario (ej: 'nombre').
3. **Consulta SQL (optionsType: 'query')**
- Para relaciones complejas o filtradas.
- \`optionsQuery\`: Tu consulta SQL.
- Usa \`<?php echo $TABLE_PREFIX ?>\` para el prefijo.
- Usa \`<?php echo $ESCAPED_FILTER_VALUE ?>\` para filtros dinámicos.
**Visualización del 'list' (\`listType\`):**
- \`pulldown\`: Select estándar.
- \`radios\`: Botones de radio.
- \`checkboxes\`: Múltiples opciones (array).
- \`pulldownMulti\`: Select múltiple.
---
### Otros Campos Comunes
- **textfield**: Texto corto (VARCHAR). Opciones: \`defaultValue\`, \`fieldWidth\`.
- **textbox**: Texto largo (TEXT). Opciones: \`fieldHeight\`.
- **wysiwyg**: Editor HTML rico. Opciones: \`allowUploads\`.
- **date**: Fecha/Hora. Opciones: \`showTime\` (bool), \`use24HourFormat\` (bool).
- **checkbox**: Booleano. Configura \`checkedValue\` (ej: 1) y \`uncheckedValue\` (ej: 0).
- **upload**: Subida de archivos. Guarda la ruta relativa.
- **multitext**: Estructura JSON para guardar múltiples sub-valores en un solo campo.
- **codigo**: Editor de código fuente.
- **separator**: Solo visual, para organizar el formulario de edición.
**Atributos Globales:**
- \`isRequired\`: Obligatorio.
- \`isUnique\`: Valor único en la tabla.
- \`label\`: Nombre visible para el humano.
- \`name\`: Nombre de la columna en DB (slug).
`
}
}
]
};
}
};
export function registerGuiaCamposTablasPrompt(server) {
server.prompt(
guiaCamposTablasPrompt.name,
guiaCamposTablasPrompt.description,
guiaCamposTablasPrompt.args,
guiaCamposTablasPrompt.handler
);
}

View File

@@ -0,0 +1,13 @@
import { registerGuiaCamposTablasPrompt } from './guiaCamposTablas.js';
/**
* Register all prompts on the MCP server.
*
* Removed duplicates (content already covered by resources):
* - estructuraDatosUpload → covered by guia-registros resource
* - filtrosTwig → covered by guia-twig-filters resource
* - guiaTiposCamposModulos → covered by guia-builder-vars resource
*/
export function registerPrompts(server) {
registerGuiaCamposTablasPrompt(server);
}

View File

@@ -0,0 +1,161 @@
import { EventEmitter } from "events";
function safeClone(value) {
try {
return JSON.parse(
JSON.stringify(value, (_, val) => (typeof val === "bigint" ? val.toString() : val))
);
} catch (error) {
return { error: `Serialization failed: ${error.message}` };
}
}
function formatError(error) {
if (!error) {
return null;
}
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
};
}
return {
name: "Error",
message: typeof error === "string" ? error : JSON.stringify(error),
};
}
export class McpRequestMonitor extends EventEmitter {
constructor(options = {}) {
super();
const { maxEntries = 300 } = options;
this.maxEntries = maxEntries;
this.entries = [];
this.nextId = 1;
}
start(method, request, extra = {}) {
const clonedRequest = safeClone(request);
let requestChars = 0;
try { requestChars = JSON.stringify(clonedRequest).length; } catch {}
const entry = {
id: this.nextId++,
method,
timestamp: new Date().toISOString(),
status: "pending",
durationMs: null,
sessionId: extra.sessionId || extra.requestInfo?.headers?.["mcp-session-id"] || null,
requestHeaders: extra.requestInfo?.headers ? { ...extra.requestInfo.headers } : undefined,
request: clonedRequest,
response: null,
error: null,
toolName: method === "tools/call" ? request?.params?.name || null : null,
requestChars,
responseChars: 0,
_startedAt: Date.now(),
};
this.entries.push(entry);
this.trimEntries();
this.emit("summary", this.toSummary(entry));
return entry;
}
finish(entry, response) {
if (!entry) return;
entry.status = "success";
entry.durationMs = Date.now() - entry._startedAt;
entry.response = safeClone(response);
try { entry.responseChars = JSON.stringify(entry.response).length; } catch {}
this.emit("summary", this.toSummary(entry));
}
fail(entry, error) {
if (!entry) return;
entry.status = "error";
entry.durationMs = Date.now() - entry._startedAt;
entry.error = formatError(error);
try { entry.responseChars = JSON.stringify(entry.error).length; } catch {}
this.emit("summary", this.toSummary(entry));
}
getSummaries() {
return this.entries.map((entry) => this.toSummary(entry));
}
getEntryById(id) {
const numericId = typeof id === "number" ? id : Number(id);
const entry = this.entries.find((item) => item.id === numericId);
if (!entry) return null;
const { _startedAt, ...rest } = entry;
return rest;
}
toSummary(entry) {
return {
id: entry.id,
method: entry.method,
timestamp: entry.timestamp,
status: entry.status,
durationMs: entry.durationMs,
sessionId: entry.sessionId,
errorMessage: entry.error?.message || null,
toolName: entry.toolName || null,
requestChars: entry.requestChars || 0,
responseChars: entry.responseChars || 0,
};
}
trimEntries() {
if (this.entries.length <= this.maxEntries) {
return;
}
this.entries.splice(0, this.entries.length - this.maxEntries);
}
getSessionStats() {
const statsMap = {};
for (const entry of this.entries) {
const sid = entry.sessionId;
if (!sid) continue;
if (!statsMap[sid]) {
statsMap[sid] = {
sessionId: sid,
totalRequests: 0,
totalToolCalls: 0,
totalRequestChars: 0,
totalResponseChars: 0,
toolBreakdown: {},
errorCount: 0,
};
}
const stats = statsMap[sid];
stats.totalRequests++;
stats.totalRequestChars += entry.requestChars || 0;
stats.totalResponseChars += entry.responseChars || 0;
if (entry.status === "error") stats.errorCount++;
if (entry.toolName) {
stats.totalToolCalls++;
if (!stats.toolBreakdown[entry.toolName]) {
stats.toolBreakdown[entry.toolName] = { count: 0, requestChars: 0, responseChars: 0, errors: 0, totalDurationMs: 0 };
}
const tb = stats.toolBreakdown[entry.toolName];
tb.count++;
tb.requestChars += entry.requestChars || 0;
tb.responseChars += entry.responseChars || 0;
if (entry.status === "error") tb.errors++;
if (entry.durationMs != null) tb.totalDurationMs += entry.durationMs;
}
}
for (const stats of Object.values(statsMap)) {
for (const tb of Object.values(stats.toolBreakdown)) {
tb.avgDurationMs = tb.count > 0 ? Math.round(tb.totalDurationMs / tb.count) : 0;
delete tb.totalDurationMs;
}
}
return Object.values(statsMap);
}
}

View File

@@ -0,0 +1,8 @@
/**
* Resources registration.
* Documentation has been moved to the scaffold (docs/).
* No MCP resources are registered — Claude reads docs directly from the workspace.
*/
export function registerResources(server) {
// No resources — docs live in the project's docs/ directory
}

100
mcp-server/server.js Normal file
View File

@@ -0,0 +1,100 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { McpRequestMonitor } from "./requestMonitor.js";
import { broadcastSse } from "./monitor.js";
// Tool handlers map for retry functionality (global, shared across sessions)
export const toolHandlers = new Map();
// Registration functions - set by index.js
let _registerPrompts = null;
let _registerTools = null;
let _registerResources = null;
// Shared request monitor instance
let _requestMonitor = null;
/**
* Set the registration functions (called once from index.js)
*/
export function setRegistrationFunctions({ registerPrompts, registerTools, registerResources }) {
_registerPrompts = registerPrompts;
_registerTools = registerTools;
_registerResources = registerResources;
}
/**
* Create and configure the MCP server
* Each session should get its own server instance
*/
export function createMcpServer() {
const server = new McpServer({
name: "acai-code-mcp-server",
version: "1.0.0",
});
// Intercept tool registration to capture handlers for retry/resend from monitor
const originalTool = server.tool.bind(server);
server.tool = (name, ...args) => {
const handler = args[args.length - 1];
toolHandlers.set(name, { handler });
return originalTool(name, ...args);
};
return server;
}
/**
* Create a fully configured server for a new session
* This creates a new McpServer instance with all tools/prompts/resources registered
* IMPORTANT: MCP SDK only supports one transport per server, so each session needs its own server
*/
export function createSessionServer() {
const server = createMcpServer();
// Wrap with request monitoring BEFORE registering tools/prompts
if (_requestMonitor) {
wrapServerWithMonitor(server, _requestMonitor);
}
// Register all tools, prompts, and resources
if (_registerPrompts) _registerPrompts(server);
if (_registerTools) _registerTools(server);
if (_registerResources) _registerResources(server);
return server;
}
/**
* Wrap a server's request handlers with monitoring
*/
function wrapServerWithMonitor(server, monitor) {
const originalSetRequestHandler = server.server.setRequestHandler.bind(server.server);
server.server.setRequestHandler = (schema, handler) => {
const method = schema.shape.method.value;
return originalSetRequestHandler(schema, async (request, extra) => {
const entry = monitor.start(method, request, extra);
try {
const result = await handler(request, extra);
monitor.finish(entry, result);
return result;
} catch (error) {
monitor.fail(entry, error);
throw error;
}
});
};
}
/**
* Create and configure the request monitor
*/
export function createRequestMonitor() {
_requestMonitor = new McpRequestMonitor();
// Broadcast summary updates via SSE
_requestMonitor.on("summary", (summary) => {
broadcastSse("summary", summary);
});
return _requestMonitor;
}

92
mcp-server/stdio.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* Acai Code MCP Server - Stdio Entry Point
*
* Used when Claude Code launches the MCP server directly via .mcp.json.
* Reads credentials from .acai file on each tool call (auto-refresh on token renewal).
*/
import fs from "fs";
import path from "path";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMcpServer } from "./server.js";
import { registerPrompts } from "./prompts/index.js";
import { registerTools } from "./tools/index.js";
import { registerResources } from "./resources/index.js";
import { sessionCredentials } from "./auth/credentials.js";
// Create server instance
const server = createMcpServer();
registerPrompts(server);
registerTools(server);
registerResources(server);
// Static env vars (web_url and website don't change, token does)
const projectDir = process.env.ACAI_PROJECT_DIR || "";
const website = process.env.ACAI_WEBSITE || "";
const webUrl = process.env.ACAI_WEB_URL || "";
const derivedForgeHost = (() => {
if (!webUrl) return "";
try {
const parsed = new URL(webUrl);
return parsed.hostname.includes("forge.acaisuite.com") ? parsed.host : "";
} catch {
return "";
}
})();
const apiWebUrl = process.env.ACAI_API_WEB_URL || (derivedForgeHost ? "http://web:80/" : webUrl);
const forgeHost = process.env.ACAI_FORGE_HOST || derivedForgeHost;
const acaiFilePath = projectDir ? path.join(projectDir, ".acai") : "";
// Read fresh credentials from .acai file
function readFreshCredentials() {
let token = process.env.ACAI_TOKEN || "";
let tokenHash = process.env.ACAI_TOKEN_HASH || "";
// If .acai file exists, read fresh token from disk (renewed by Python server)
if (acaiFilePath) {
try {
const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
if (data.token) token = data.token;
if (data.tokenHash) tokenHash = data.tokenHash;
} catch {
// Fall back to env vars if .acai can't be read
}
}
return {
token,
tokenHash,
website,
web_url: webUrl,
api_web_url: apiWebUrl,
forge_host: forgeHost,
profileName: "stdio",
role: "developer",
};
}
if (!webUrl) {
console.error("[MCP stdio] WARNING: No ACAI_WEB_URL in environment. Tools will fail.");
}
// Set initial credentials
sessionCredentials.set("_default", readFreshCredentials());
// Intercept tool calls to refresh credentials from .acai before each call
const _origSetHandler = server.server.setRequestHandler;
server.server.setRequestHandler = (schema, handler) => {
return _origSetHandler.call(server.server, schema, async (request, extra) => {
// Re-read .acai on every tool call to pick up renewed tokens
const freshCreds = readFreshCredentials();
sessionCredentials.set("_default", freshCreds);
if (extra?.sessionId) {
sessionCredentials.set(extra.sessionId, freshCreds);
}
return handler(request, extra);
});
};
// Connect via stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`[MCP stdio] Connected — ${website}${webUrl} (project: ${projectDir})`);

View File

@@ -0,0 +1,108 @@
import { z } from "zod";
import fs from "fs";
import path from "path";
import axios from "axios";
import { sessionCredentials } from "../../auth/credentials.js";
import { withAuthParams } from "../helpers/authSchema.js";
const LOCAL_SERVER_URL = `http://localhost:${process.env.ACAI_HOST_PORT || 29871}`;
export function registerAuthTools(server) {
server.tool(
"refresh_acai_token",
`Refresh the Acai JWT token when it has expired (403 "Token no válido" errors). This re-reads the token from the .acai file on disk. If the token on disk is also expired, it calls the Python server to renew it. Use this tool when any other tool fails with a 403 token error.`,
withAuthParams({}),
{ readOnlyHint: false, destructiveHint: false },
async (_args, extra) => {
try {
const projectDir = process.env.ACAI_PROJECT_DIR || "";
const acaiFilePath = projectDir ? path.join(projectDir, ".acai") : "";
if (!acaiFilePath) {
return {
content: [{ type: "text", text: JSON.stringify({ success: false, error: "ACAI_PROJECT_DIR not set" }) }],
isError: true,
};
}
// Step 1: Try reading fresh token from .acai (Python server may have already refreshed it)
let token = "";
let tokenHash = "";
let domain = "";
try {
const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
token = data.token || "";
tokenHash = data.tokenHash || "";
domain = data.domain || "";
} catch (e) {
return {
content: [{ type: "text", text: JSON.stringify({ success: false, error: `Cannot read .acai: ${e.message}` }) }],
isError: true,
};
}
// Step 2: Check if token is expired by decoding JWT
let isExpired = false;
try {
const payload = token.split(".")[1];
const decoded = JSON.parse(Buffer.from(payload, "base64").toString());
isExpired = Date.now() / 1000 > (decoded.exp || 0) - 300;
} catch {
isExpired = true;
}
// Step 3: If expired, ask Python server to refresh it
if (isExpired) {
try {
// Call the compile-module endpoint pattern — but we need a refresh endpoint
// Use the server's existing auto-refresh: just call any endpoint that triggers refresh
// The simplest: GET /api/projects which auto-refreshes expired tokens
const res = await axios.get(`${LOCAL_SERVER_URL}/api/projects`, { timeout: 15000 });
// Re-read .acai after server refreshed it
const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
token = data.token || "";
tokenHash = data.tokenHash || "";
} catch (e) {
return {
content: [{ type: "text", text: JSON.stringify({ success: false, error: `Token refresh failed: ${e.message}` }) }],
isError: true,
};
}
}
// Step 4: Update credentials in memory
const webUrl = process.env.ACAI_WEB_URL || "";
const website = domain || process.env.ACAI_WEBSITE || "";
const freshCreds = {
token,
tokenHash,
website,
web_url: webUrl,
profileName: "stdio",
role: "developer",
};
sessionCredentials.set("_default", freshCreds);
if (extra?.sessionId) {
sessionCredentials.set(extra.sessionId, freshCreds);
}
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: "Token refreshed successfully",
expired_before: isExpired,
domain: website,
}, null, 2),
}],
};
} catch (error) {
return {
content: [{ type: "text", text: JSON.stringify({ success: false, error: error.message }) }],
isError: true,
};
}
}
);
}

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
export function registerAcaiDeleteTool(server) {
server.tool(
"acai-delete",
"Delete a file inside the project. Destructive operation.",
{
file_path: z.string().describe("Path relative to the project root"),
expected_sha256: z.string().optional().describe("Optional safety check before deletion"),
},
{ readOnlyHint: false, destructiveHint: true },
async ({ file_path, expected_sha256 }) => {
try {
const validationError = validateRequired({ file_path }, ["file_path"], "acai-delete");
if (validationError) return validationError;
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("POST", "/api/files/delete", {
project: projectSlug,
projectDir: projectDir,
relativePath: file_path,
expectedSha256: expected_sha256 || "",
});
if (!result.data?.success) {
return buildLocalFileErrorResponse("acai-delete", result, { file_path });
}
const data = result.data;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
file_path: data.filePath,
deleted: data.deleted,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "acai-delete", { file_path });
}
}
);
}

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
export function registerAcaiGlobTool(server) {
server.tool(
"acai-glob",
"Find project files by path pattern. Returns compact relative paths only.",
{
pattern: z.string().describe("Glob-style pattern relative to the project root, e.g. 'template/estandar/modulos/**/index-base.tpl'"),
base_path: z.string().optional().describe("Optional base directory relative to the project root"),
limit: z.number().int().positive().max(200).optional().describe("Maximum number of paths to return"),
},
{ readOnlyHint: true, destructiveHint: false },
async ({ pattern, base_path, limit }) => {
try {
const validationError = validateRequired({ pattern }, ["pattern"], "acai-glob");
if (validationError) return validationError;
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("GET", "/api/files/glob", null, {
project: projectSlug,
projectDir,
pattern,
basePath: base_path,
limit,
});
if (!result.data?.success) {
return buildLocalFileErrorResponse("acai-glob", result, { pattern, base_path });
}
const data = result.data;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
pattern: data.pattern,
base_path: data.basePath,
matches: data.matches,
total_matches: data.totalMatches,
truncated: data.truncated,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "acai-glob", { pattern, base_path });
}
}
);
}

View File

@@ -0,0 +1,65 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
export function registerAcaiGrepTool(server) {
server.tool(
"acai-grep",
"Search text inside project files with compact line-level results. Supports optional glob filtering.",
{
pattern: z.string().describe("Text or regex pattern to search for"),
base_path: z.string().optional().describe("Optional base directory relative to the project root"),
glob: z.string().optional().describe("Optional file path glob filter, e.g. '**/index-base.tpl'"),
limit: z.number().int().positive().max(100).optional().describe("Maximum number of matches to return"),
case_sensitive: z.boolean().optional().describe("Whether matching should be case-sensitive"),
regex: z.boolean().optional().describe("Treat pattern as a regular expression"),
},
{ readOnlyHint: true, destructiveHint: false },
async ({ pattern, base_path, glob, limit, case_sensitive, regex }) => {
try {
const validationError = validateRequired({ pattern }, ["pattern"], "acai-grep");
if (validationError) return validationError;
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("POST", "/api/files/grep", {
project: projectSlug,
projectDir,
pattern,
basePath: base_path,
glob,
limit,
caseSensitive: case_sensitive,
regex,
});
if (!result.data?.success) {
return buildLocalFileErrorResponse("acai-grep", result, { pattern, base_path, glob });
}
const data = result.data;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
pattern: data.pattern,
base_path: data.basePath,
glob: data.glob,
regex: data.regex,
case_sensitive: data.caseSensitive,
files_scanned: data.filesScanned,
matches: data.matches.map((match) => ({
file_path: match.filePath,
line: match.line,
match_preview: match.matchPreview,
})),
total_matches: data.totalMatches,
truncated: data.truncated,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "acai-grep", { pattern, base_path, glob });
}
}
);
}

View File

@@ -0,0 +1,60 @@
import axios from "axios";
import path from "path";
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js";
export function getCurrentProjectInfo() {
const projectDir = process.env.ACAI_PROJECT_DIR || "";
if (!projectDir) {
throw new Error("ACAI_PROJECT_DIR not set");
}
return {
projectDir,
projectSlug: path.basename(path.resolve(projectDir)),
};
}
export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) {
const headers = getLocalServerHeaders();
if (method === "GET") {
const response = await axios.get(`${LOCAL_SERVER_URL}${endpoint}`, {
params: query || undefined,
headers,
timeout: 30000,
validateStatus: (status) => status < 600,
});
return { status: response.status, data: response.data };
}
const response = await axios.post(`${LOCAL_SERVER_URL}${endpoint}`, payload || {}, {
headers,
timeout: 30000,
validateStatus: (status) => status < 600,
});
return { status: response.status, data: response.data };
}
export function buildLocalFileErrorResponse(toolName, result, extra = {}) {
const payload = result?.data || {};
const message =
payload.message ||
payload.error ||
payload.compileError ||
`HTTP ${result?.status || 500}`;
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: {
code: `HTTP_${result.status}`,
message,
context: toolName,
...extra,
...payload,
},
}, null, 2),
}],
isError: true,
};
}

View File

@@ -0,0 +1,15 @@
import { registerAcaiViewTool } from "./view.js";
import { registerAcaiWriteTool } from "./write.js";
import { registerAcaiLineReplaceTool } from "./lineReplace.js";
import { registerAcaiDeleteTool } from "./delete.js";
import { registerAcaiGlobTool } from "./glob.js";
import { registerAcaiGrepTool } from "./grep.js";
export function registerFileTools(server) {
registerAcaiViewTool(server);
registerAcaiGlobTool(server);
registerAcaiGrepTool(server);
registerAcaiWriteTool(server);
registerAcaiLineReplaceTool(server);
registerAcaiDeleteTool(server);
}

View File

@@ -0,0 +1,67 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
export function registerAcaiLineReplaceTool(server) {
server.tool(
"acai-line-replace",
"Replace a validated line block in an existing file. Preferred for editing existing files while minimizing token usage.",
{
file_path: z.string().describe("Path relative to the project root"),
first_replaced_line: z.number().int().positive().describe("1-indexed first line of the target block"),
last_replaced_line: z.number().int().positive().describe("1-indexed last line of the target block"),
search: z.string().describe("Expected current content for validation. Must match the selected block exactly."),
replace: z.string().describe("Replacement content for the selected block"),
expected_sha256: z.string().optional().describe("Optional full-file sha check from a prior acai-view"),
},
{ readOnlyHint: false, destructiveHint: false },
async ({ file_path, first_replaced_line, last_replaced_line, search, replace, expected_sha256 }) => {
try {
const validationError = validateRequired(
{ file_path, first_replaced_line, last_replaced_line, search },
["file_path", "first_replaced_line", "last_replaced_line", "search"],
"acai-line-replace"
);
if (validationError) return validationError;
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("POST", "/api/files/line-replace", {
project: projectSlug,
projectDir: projectDir,
relativePath: file_path,
firstLine: first_replaced_line,
lastLine: last_replaced_line,
search,
replace,
expectedSha256: expected_sha256 || "",
});
if (!result.data?.success) {
return buildLocalFileErrorResponse("acai-line-replace", result, {
file_path,
first_replaced_line,
last_replaced_line,
});
}
const data = result.data;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
file_path: data.filePath,
first_replaced_line: data.firstLine,
last_replaced_line: data.lastLine,
new_sha256: data.newSha256,
changed: data.changed,
compiled: data.compiled || false,
compile_result: data.compileResult || null,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "acai-line-replace", { file_path, first_replaced_line, last_replaced_line });
}
}
);
}

View File

@@ -0,0 +1,60 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
export function registerAcaiViewTool(server) {
server.tool(
"acai-view",
"Read a project file with optional line ranges. Returns only the requested slice plus compact metadata for safe incremental edits.",
{
file_path: z.string().describe("Path relative to the project root"),
start_line: z.number().int().positive().optional().describe("1-indexed start line. Defaults to 1."),
end_line: z.number().int().positive().optional().describe("1-indexed end line. If omitted, the server returns a bounded chunk."),
},
{ readOnlyHint: true, destructiveHint: false },
async ({ file_path, start_line, end_line }) => {
try {
const validationError = validateRequired({ file_path }, ["file_path"], "acai-view");
if (validationError) return validationError;
if (end_line !== undefined && start_line !== undefined && end_line < start_line) {
return {
content: [{ type: "text", text: JSON.stringify({ success: false, error: "end_line must be greater than or equal to start_line" }, null, 2) }],
isError: true,
};
}
const { projectSlug } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("GET", "/api/files/read", null, {
project: projectSlug,
projectDir: getCurrentProjectInfo().projectDir,
relativePath: file_path,
startLine: start_line,
endLine: end_line,
});
if (!result.data?.success) {
return buildLocalFileErrorResponse("acai-view", result, { file_path });
}
const data = result.data;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
file_path: data.filePath,
start_line: data.startLine,
end_line: data.endLine,
total_lines: data.totalLines,
sha256: data.sha256,
content: data.content,
truncated: data.truncated,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "acai-view", { file_path });
}
}
);
}

View File

@@ -0,0 +1,59 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
export function registerAcaiWriteTool(server) {
server.tool(
"acai-write",
`Write a full file inside the project. Use for new files or full rewrites. Prefer acai-line-replace for targeted edits.
Before writing, check the matching documentation for the file type:
- If the file is an index-base template (\`index-base.tpl\` or \`index-base.html\`), make sure you have read \`docs/module-creation-guide.md\` and \`docs/builder-fields.md\`
- If the file is a \`.js\` or \`.css\`, make sure you have read \`docs/css-js-conventions.md\`
- If the file is a module or global hook PHP file, make sure you have read \`docs/hooks-and-api.md\``,
{
file_path: z.string().describe("Path relative to the project root"),
content: z.string().describe("Full file content to write"),
mode: z.enum(["create_or_overwrite", "create_only"]).optional().default("create_or_overwrite").describe("Write mode"),
expected_sha256: z.string().optional().describe("Optional optimistic concurrency check from a prior acai-view"),
},
{ readOnlyHint: false, destructiveHint: false },
async ({ file_path, content, mode = "create_or_overwrite", expected_sha256 }) => {
try {
const validationError = validateRequired({ file_path }, ["file_path"], "acai-write");
if (validationError) return validationError;
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("POST", "/api/files/write", {
project: projectSlug,
projectDir: projectDir,
relativePath: file_path,
content,
mode,
expectedSha256: expected_sha256 || "",
});
if (!result.data?.success) {
return buildLocalFileErrorResponse("acai-write", result, { file_path });
}
const data = result.data;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
file_path: data.filePath,
created: data.created,
overwritten: data.overwritten,
sha256: data.sha256,
compiled: data.compiled || false,
compile_result: data.compileResult || null,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "acai-write", { file_path });
}
}
);
}

View File

@@ -0,0 +1,357 @@
# Acai CMS Endpoints Reference
Este documento mapea todos los endpoints de Acai CMS utilizados por las herramientas MCP.
## Endpoints Base
- **CMS Admin**: `https://[website]/admin.php` - Panel administrativo principal
- **Viewer Functions**: `https://[website]/cms/lib/viewer_functions.php` - API de funciones Acai
- **SAAS API**: `https://ws.cocosolution.com/api/schemas/` - API SaaS para esquemas
- **File Upload**: `https://[website]/lib/menus/modals/plupload/multiupload/upload.php` - Subir archivos
## Categoría: Módulos (saveApartados)
### 1. Generar módulo desde HTML
**Endpoint**: `https://acai.cms.cocosolution.com/admin.php?menu=apartados&action=edit&generateModuleFromString=1`
**Método**: POST
**Usado por**: `save_module`
**Headers**: `Content-Type: application/json`, `X-Acai-Token`
**Payload**: moduleData object con html, htmlParsed, vars, etc.
### 2. Obtener esquemas de módulos
**Endpoint**: `/cms/lib/viewer_functions.php`
**Método**: POST via getApiClient
**Usado por**: `save_module`, `saveGeneralSection`, `check_module`, `list_modules`, `get_module`
**Action**: `getModuleSchemas`
**Payload via getCommonParams**:
```javascript
{
action_ws: "getModuleSchemas",
ids: [moduleId], // opcional, para un módulo específico
full: 1 // opcional, para obtener contenido completo
}
```
### 3. Verificar módulo
**Endpoint**: `https://[website]/cms/lib/viewer_functions.php?action_ws=checkModuleCode`
**Método**: POST
**Usado por**: `check_module`
**Payload**:
```javascript
{
moduleName: string,
vars: object // variables de prueba
}
```
## Categoría: Secciones Generales (saveLexicalData)
### 1. Guardar sección con contenido Twig/HTML
**Endpoint**: `https://[website]/cms/lib/viewer_functions.php`
**Método**: POST
**Usado por**: `saveGeneralSection`
**Action**: `saveLexicalData`
**Payload**:
```javascript
{
action_ws: 'saveLexicalData',
token: credentials.token,
tokenHash: credentials.tokenHash,
content: string, // HTML parsed content
rawDataSended: true,
endPointFolder: string, // e.g., 'custom-productos'
parserType: '2' | '0', // 2=Twig, 0=Acai
aditionalFiles: [ // CSS, JS files
{
path: string,
fileName: string,
content: string
}
]
}
```
## Categoría: Registros (CRUD)
### 1. Crear/Actualizar registro
**Endpoint**: `${CMS_URL}/admin.php`
**Método**: POST
**Usado por**: `create_or_update_record`
**Content-Type**: `application/x-www-form-urlencoded`
**Params**:
```
menu={tableName}
_defaultAction=save
num={recordId} // empty para crear
type=
preSaveTempId={timestamp}
action=save
{fieldname}={value} // campos del registro
{fieldname}:year, :mon, etc // para campos date
enlace={value}
```
### 2. Listar registros
**Endpoint**: `${CMS_URL}/admin.php?menu={tableName}&json=1&page={n}&keyword={q}`
**Método**: GET
**Usado por**: `list_table_records`
**Headers**: `X-Acai-Token`, `X-Requested-With: XMLHttpRequest`
### 3. Eliminar registros
**Endpoint**: `${CMS_URL}/admin.php`
**Método**: POST
**Usado por**: `delete_table_records`
**Params**:
```
menu={tableName}
_defaultAction=list
page=1
_advancedAction=eraseRecords
_advancedActionSubmit=Ejecutar
selectedRecords[]={id1}
selectedRecords[]={id2}
```
## Categoría: Archivos (saveFileBuilder, removeFileBuilder)
### 1. Escribir archivo
**Endpoint**: `/cms/lib/viewer_functions.php`
**Método**: POST via getApiClient
**Usado por**: `write_file`
**Action**: `saveFileBuilder`
**Payload via getCommonParams**:
```javascript
{
action_ws: "saveFileBuilder",
path: string, // ej: '/modulos/mymodule/'
fileName: string, // ej: 'style.css'
content: string,
rawDataSended: false,
rootFolder: false
}
```
### 2. Listar archivos (FTP)
**Endpoint**: `/cms/lib/viewer_functions.php`
**Método**: POST via getApiClient
**Usado por**: `list_files`
**Action**: `getFTPFiles`
**Payload via getCommonParams**:
```javascript
{
action_ws: "getFTPFiles",
path: string // directorio a listar
}
```
### 3. Eliminar archivo
**Endpoint**: `/cms/lib/viewer_functions.php`
**Método**: POST via getApiClient
**Usado por**: `delete_file`
**Action**: `removeFileBuilder`
**Payload via getCommonParams**:
```javascript
{
action_ws: "removeFileBuilder",
path: string // ruta del archivo
}
```
## Categoría: Tablas (Database Schema)
### 1. Listar tablas (SaaS)
**Endpoint**: `${SAAS_URL}`
**Método**: POST
**Usado por**: `list_tables`
**Payload**:
```javascript
{
action: 'getSchemaTables',
type: 'acai'
}
```
**Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`
### 2. Obtener esquema tabla (SaaS)
**Endpoint**: `${SAAS_URL}`
**Método**: POST
**Usado por**: `get_table_schema`
**Payload**:
```javascript
{
action: 'getSchemaTables',
type: 'acai'
}
```
### 3. Actualizar esquema tabla (SaaS + CMS)
**Endpoint SaaS**: `${SAAS_URL}` (PUT)
**Método**: PUT
**Usado por**: `update_table_schema`
**Payload**:
```javascript
{
action: "saveSchema",
type: "acai",
schema: object, // esquema completo o parcial
dir: "",
id: tableName
}
```
**Luego sincronizar en CMS**:
**Endpoint CMS**: `/cms/lib/viewer_functions.php`
**Método**: POST via getApiClient
**Action**: `updateAllSchemas`
**Payload via getCommonParams**:
```javascript
{
action_ws: "updateAllSchemas",
tokenHash: credentials.tokenHash
}
```
### 4. Crear tabla
**Endpoint**: `${CMS_URL}/admin.php`
**Método**: POST
**Usado por**: `create_table`
**Params**:
```
menu=database
_defaultAction=addTable_save
type={multi|single|category|separador}
preset=
enlace={on|''}
seo_metas={on|''}
menuName={name}
menuOrder={order}
tableName={name}
```
### 5. Eliminar tabla
**Endpoint**: `${CMS_URL}/admin.php`
**Método**: POST
**Usado por**: `delete_table`
**Params**:
```
menu=database
action=editTable
dropTable=1
tableName={name}
```
### 6. Editar campos tabla
**Endpoint**: `${CMS_URL}/admin.php`
**Método**: POST
**Usado por**: `edit_table_field`
**Params**:
```
menu=database
_defaultAction=editTable
editField=1
tableName=cms_{tableName}
save=1
multipleFields={JSON.stringify(fieldArray)}
```
### 7. Eliminar campo tabla
**Endpoint**: `${CMS_URL}/admin.php`
**Método**: POST
**Usado por**: `delete_table_field`
**Params**:
```
menu=database
action=editTable
editField=1
tableName=cms_{tableName}
fieldname={fieldname}
deleteField=1
```
### 8. Obtener templates tabla (general section)
**Endpoint**: `/cms/lib/viewer_functions.php`
**Método**: POST via getApiClient
**Usado por**: `get_table_templates`
**Action**: `getTableData`
**Payload via getCommonParams**:
```javascript
{
action_ws: "getTableData",
menu: tableName
}
```
## Categoría: Media (Upload)
### 1. Subir imagen a campo
**Endpoint**: `${CMS_URL}/lib/menus/modals/plupload/multiupload/upload.php?menu={table}&fieldName={field}&num={recordId}&preSaveTempId=`
**Método**: POST (FormData)
**Usado por**: `upload_record_image`
**Form Fields**:
```
file={File buffer} // File object
```
### 2. Listar uploads campo
**Endpoint**: `${CMS_URL}/admin.php?menu={table}&action=uploadList&fieldName={field}&num={recordId}&preSaveTempId=&json=1`
**Método**: GET
**Usado por**: `list_record_uploads`
**Headers**: `X-Acai-Token`
### 3. Reemplazar upload
**Endpoint**: `${CMS_URL}/admin.php`
**Método**: POST (FormData)
**Usado por**: `replace_record_image`
**Form Fields**:
```
_defaultAction=uploadModify
menu={tableName}
fieldName={fieldName}
num={recordId}
preSaveTempId=
save=1
uploadNums[]={uploadId}
{uploadId}_file={File buffer}
{uploadId}_name={originalFilePath}
{uploadId}_alt={altText}
action=uploadModify
```
### 4. Eliminar upload
**Endpoint**: `${CMS_URL}/admin.php?menu={table}&action=uploadErase&fieldName={field}&uploadNum={id}&num={recordId}&preSaveTempId=`
**Método**: GET
**Usado por**: `delete_record_upload`
**Headers**: `X-Acai-Token`, `X-Requested-With: XMLHttpRequest`
## Patrones Comunes
### getApiClient Calls
```javascript
const client = getApiClient(extra.sessionId);
const response = await client.post("/cms/lib/viewer_functions.php", getCommonParams(extra.sessionId, {
action_ws: "actionName",
// ... otros params
}));
```
### getCommonParams
Agrega automáticamente:
- token
- tokenHash
- website
- session info
### Headers Recurrentes
```javascript
{
"X-Acai-Token": credentials.token,
"Content-Type": "application/json" | "application/x-www-form-urlencoded"
}
```
## Notas Importantes
1. **Construcción de URLs**: Algunos endpoints usan la URL base dinámicamente (`https://{website}/...`) mientras otros usan `CMS_URL` configurado.
2. **Parámetros de formulario**: Algunos endpoints esperan URLSearchParams, otros JSON.
3. **Token Auth**: Algunos usan `X-Acai-Token`, otros pasan token en payload.
4. **Respuestas**: Varían entre `{success: true}`, `{result: true}`, o respuestas direc tas.

View File

@@ -0,0 +1,270 @@
# Error Handling System for Tools
Centralizado error handling para todas las herramientas MCP del servidor.
## Características
**Manejo consistente de errores** - Todas las herramientas retornan el mismo formato
**Logging automático** - Todos los errores se registran en consola
**Validación de parámetros** - Validación requerida y de tipos
**Detección de errores API** - Identifica patrones comunes de error en respuestas
**Información contextual** - Cada error incluye el contexto de dónde ocurrió
## Funciones Disponibles
### `handleToolError(error, context, additionalInfo)`
Maneja cualquier error y retorna una respuesta formateada.
```javascript
import { handleToolError } from "../helpers/errorHandler.js";
try {
// tu código
} catch (error) {
return handleToolError(error, 'my_tool', { userId: 123 });
}
```
**Retorna:**
```json
{
"success": false,
"error": {
"code": "ECONNREFUSED",
"message": "connect ECONNREFUSED 127.0.0.1:3000",
"context": "my_tool",
"userId": 123
}
}
```
---
### `handleApiResponse(data, context)`
Detecta errores en respuestas de API (busca patrones comunes).
```javascript
const response = await axios.post(url, payload);
// Detecta automáticamente: error, Error, PHPSyntax, success: false, etc.
const apiError = handleApiResponse(response.data, 'save_module');
if (apiError) return apiError;
```
---
### `validateRequired(params, requiredFields, context)`
Valida que los parámetros requeridos estén presentes.
```javascript
const error = validateRequired(
{ name: "Juan", email: "" },
['name', 'email'],
'create_user'
);
// error porque email está vacío
```
---
### `validateTypes(params, schema, context)`
Valida tipos de datos.
```javascript
const error = validateTypes(
{ age: "25", active: true },
{ age: 'number', active: 'boolean' },
'create_user'
);
// error porque age es string, no number
```
---
### `createValidator(requiredFields, typeSchema)`
Crea una función validadora reutilizable.
```javascript
const validateUserInput = createValidator(
['name', 'email'],
{ age: 'number', active: 'boolean' }
);
// Usar en múltiples lugares
const error = validateUserInput(params, 'create_user');
if (error) return error;
```
---
### `withErrorHandling(handler, toolName)`
Envuelve un handler para manejar errores automáticamente.
```javascript
const safeHandler = withErrorHandling(
async (params, extra) => {
// tu código
},
'my_tool'
);
```
---
### `safeJsonParse(jsonString, context)`
Parse JSON seguro con manejo de errores.
```javascript
const result = safeJsonParse(jsonString, 'parse_config');
if (!result.success) {
// result.error contiene el error formateado
return result.error;
}
const data = result.data;
```
---
## Patrón Recomendado para Tools
```javascript
import { z } from "zod";
import axios from "axios";
import { withAuth, getSessionCredentials } from "../../auth/index.js";
import {
handleToolError,
handleApiResponse,
validateRequired
} from "../helpers/errorHandler.js";
export function registerMyTool(server) {
server.tool(
"my_tool",
"Descripción de la herramienta",
{
param1: z.string().describe("Parámetro 1"),
param2: z.number().describe("Parámetro 2"),
},
withAuth(async ({ param1, param2 }, extra) => {
try {
// 1. Validar parámetros requeridos
const validationError = validateRequired(
{ param1, param2 },
['param1', 'param2'],
'my_tool'
);
if (validationError) return validationError;
// 2. Obtener credenciales
const credentials = getSessionCredentials(extra.sessionId);
// 3. Hacer llamada API
const response = await axios.post(url, payload, {
headers: { /* ... */ }
});
// 4. Verificar respuesta de API
const apiError = handleApiResponse(response.data, 'my_tool');
if (apiError) return apiError;
// 5. Retornar resultado
return {
content: [{
type: "text",
text: JSON.stringify(response.data, null, 2)
}]
};
} catch (error) {
// Los errores se capturan y formatean automáticamente
return handleToolError(error, 'my_tool', { param1, param2 });
}
})
);
}
```
---
## Errores Detectados Automáticamente
`handleApiResponse()` detecta estos patrones en respuestas:
-`data.error` o `data.Error`
-`data.PHPSyntax` - Errores de sintaxis PHP
-`data.success === false` - Campo success explícito
- ✅ Strings con palabras clave: "error", "fatal", "undefined", "syntax"
- ✅ Respuestas vacías o null
---
## Formato de Error Consistente
Todos los errores retornan este formato:
```json
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Mensaje descriptivo del error",
"context": "nombre_del_tool",
"...": "información adicional"
}
}
```
---
## Migración de Tools Existentes
Para actualizar un tool existente:
1. Importar funciones de error handler
2. Reemplazar `try-catch` genérico con `handleToolError()`
3. Agregar validación con `validateRequired()`
4. Agregar `handleApiResponse()` después de llamadas API
5. Pasar información contextual útil a `handleToolError()`
**Ejemplo antes:**
```javascript
try {
// código
} catch (error) {
return {
content: [{ type: "text", text: "Error: " + error.message }],
isError: true
};
}
```
**Ejemplo después:**
```javascript
try {
// código
} catch (error) {
return handleToolError(error, 'my_tool', { extraInfo: value });
}
```
---
## Logging
Todos los errores se registran en stderr con contexto:
```
[Tool Error - save_module] Cannot read property 'website' of undefined
Stack: Error: Cannot read property 'website' of undefined
at registerSaveModuleTool (/Users/...save.js:45:20)
...
```
Esto facilita debug y auditoría de errores en producción.

View File

@@ -0,0 +1,587 @@
/**
* Acai CMS HTTP Client
*
* Centralizado helper para todas las llamadas HTTP a Acai CMS.
* Proporciona métodos consistentes para interactuar con:
* - Admin panel (admin.php)
* - Viewer functions API
* - SaaS API
* - File upload endpoints
*
* Ventajas:
* - Consistencia en headers, manejo de errores, logging
* - Reduce duplicación de código
* - Facilita mantenimiento y debugging
* - Centraliza URLs y configuración
*/
import axios from 'axios';
import { getSessionCredentials } from '../../auth/index.js';
import { CMS_URL } from '../../config/index.js';
import { assertSafeCmsTarget } from '../../utils/cmsTargetSafety.js';
/**
* AcaiHttpClient - Helper para solicitudes HTTP a Acai CMS
*/
export class AcaiHttpClient {
static resolveCmsTarget(target) {
const { publicUrl, apiUrl, forgeHost } = assertSafeCmsTarget(target, "AcaiHttpClient");
const headers = {};
if (forgeHost) {
headers.Host = forgeHost;
}
return {
publicUrl,
apiUrl,
headers,
};
}
static buildViewerUrl(target, query = "") {
const { apiUrl } = AcaiHttpClient.resolveCmsTarget(target);
const baseUrl = apiUrl.endsWith("/") ? apiUrl.slice(0, -1) : apiUrl;
return `${baseUrl}/cms/lib/viewer_functions.php${query ? `?${query}` : ""}`;
}
static buildViewerHeaders(target, extraHeaders = {}) {
const { headers } = AcaiHttpClient.resolveCmsTarget(target);
return {
"Content-Type": "application/json",
...headers,
...extraHeaders,
};
}
static async postViewerAction(target, actionWs, payload, token, tokenHash, extraHeaders = {}, timeout = 30000) {
const viewerUrl = AcaiHttpClient.buildViewerUrl(target, `action_ws=${actionWs}`);
const body = {
...payload,
token,
tokenHash,
};
return axios.post(viewerUrl, body, {
headers: AcaiHttpClient.buildViewerHeaders(target, extraHeaders),
timeout,
});
}
/**
* POST a admin.php con URLSearchParams
* @param {string} website - Website/domain (no usado, se usa CMS_URL del config)
* @param {URLSearchParams} params - Parámetros del formulario
* @param {string} token - Token Acai
* @returns {Promise<Object>} Respuesta del servidor
*/
static async postAdminForm(website, params, token) {
const cmsUrl = `${CMS_URL}/admin.php`;
try {
console.error(`[AcaiHttpClient] postAdminForm - START: ${cmsUrl}`);
const response = await axios.post(cmsUrl, params, {
headers: {
"X-Acai-Token": token,
"Content-Type": "application/x-www-form-urlencoded"
},
timeout: 30000
});
console.error(`[AcaiHttpClient] postAdminForm - SUCCESS: ${cmsUrl} (${response.status})`);
return response;
} catch (error) {
console.error(`[AcaiHttpClient] postAdminForm - ERROR: ${cmsUrl} - ${error.message}`);
if (error.response) {
console.error(`[AcaiHttpClient] Response status: ${error.response.status}`);
console.error(`[AcaiHttpClient] Response data:`, error.response.data?.substring ? error.response.data.substring(0, 200) : error.response.data);
} else if (error.code) {
console.error(`[AcaiHttpClient] Error code: ${error.code}`);
}
throw error;
}
}
/**
* POST a admin.php con FormData (para uploads)
* @param {string} website - Website/domain (no usado, se usa CMS_URL del config)
* @param {FormData} formData - Datos del formulario
* @param {string} token - Token Acai
* @returns {Promise<Object>} Respuesta del servidor
*/
static async postAdminFormData(website, formData, token) {
const cmsUrl = `${CMS_URL}/admin.php`;
try {
console.error(`[AcaiHttpClient] postAdminFormData - START: ${cmsUrl}`);
const response = await axios.post(cmsUrl, formData, {
headers: {
...formData.getHeaders(),
"X-Acai-Token": token
},
timeout: 30000
});
console.error(`[AcaiHttpClient] postAdminFormData - SUCCESS: ${cmsUrl} (${response.status})`);
return response;
} catch (error) {
console.error(`[AcaiHttpClient] postAdminFormData - ERROR: ${cmsUrl} - ${error.message}`);
if (error.response) {
console.error(`[AcaiHttpClient] Response status: ${error.response.status}`);
console.error(`[AcaiHttpClient] Response data:`, error.response.data?.substring ? error.response.data.substring(0, 200) : error.response.data);
} else if (error.code) {
console.error(`[AcaiHttpClient] Error code: ${error.code}`);
}
throw error;
}
}
/**
* GET a admin.php con query parameters
* @param {string} website - Website/domain (no usado, se usa CMS_URL del config)
* @param {URLSearchParams | string} params - Parámetros de query
* @param {string} token - Token Acai
* @returns {Promise<Object>} Respuesta del servidor
*/
static async getAdminQuery(website, params, token) {
const cmsUrl = `${CMS_URL}/admin.php`;
const queryString = params instanceof URLSearchParams
? params.toString()
: params;
try {
console.error(`[AcaiHttpClient] getAdminQuery - START: ${cmsUrl}?${queryString.substring(0, 100)}`);
const response = await axios.get(
`${cmsUrl}?${queryString}`,
{
headers: {
"X-Acai-Token": token,
"X-Requested-With": "XMLHttpRequest"
},
timeout: 30000
}
);
console.error(`[AcaiHttpClient] getAdminQuery - SUCCESS: ${cmsUrl} (${response.status})`);
return response;
} catch (error) {
console.error(`[AcaiHttpClient] getAdminQuery - ERROR: ${cmsUrl} - ${error.message}`);
if (error.response) {
console.error(`[AcaiHttpClient] Response status: ${error.response.status}`);
console.error(`[AcaiHttpClient] Response data:`, error.response.data?.substring ? error.response.data.substring(0, 200) : error.response.data);
} else if (error.code) {
console.error(`[AcaiHttpClient] Error code: ${error.code}`);
}
throw error;
}
}
/**
* POST a viewer_functions.php vía getApiClient
* Requiere llamar desde dentro de withAuth para tener acceso a getApiClient
* @param {Object} client - cliente de axios (getApiClient)
* @param {Object} payload - Payload con action_ws y otros parámetros
* @returns {Promise<Object>} Respuesta del servidor
*/
static async postViewerFunctions(client, payload) {
return client.post("/cms/lib/viewer_functions.php", payload);
}
/**
* POST a viewer_functions.php para saveLexicalData (secciones, contenido)
* @param {string} web_url - URL base del sitio (ej: http://localhost:PORT)
* @param {Object} credentials - {token, tokenHash}
* @param {Object} data - Datos a guardar
* @returns {Promise<Object>} Respuesta del servidor
*/
static async saveLexicalData(target, credentials, data) {
const viewerUrl = AcaiHttpClient.buildViewerUrl(target);
const payload = {
action_ws: 'saveLexicalData',
token: credentials.token,
tokenHash: credentials.tokenHash,
rawDataSended: true,
...data
};
return axios.post(viewerUrl, payload, {
headers: AcaiHttpClient.buildViewerHeaders(target)
});
}
/**
* POST para generar módulo desde HTML
* @param {Object} moduleData - Datos del módulo
* @param {string} token - Token Acai
* @returns {Promise<Object>} Respuesta del servidor
*/
static async generateModuleFromString(moduleData, token) {
const cmsUrl = 'https://acai.cms.cocosolution.com/admin.php?menu=apartados&action=edit&generateModuleFromString=1';
return axios.post(cmsUrl, moduleData, {
headers: {
"Content-Type": "application/json",
"X-Acai-Token": token
}
});
}
/**
* POST a viewer_functions para CMS API (insert, update, delete, get)
* @param {string} web_url - URL base del sitio (ej: http://localhost:PORT)
* @param {string} action - 'insert', 'update', 'delete', 'get'
* @param {Object} payload - Datos de la operación
* @param {string} token - Token Acai
* @returns {Promise<Object>} Respuesta del servidor
*/
static async postCmsApi(target, action, payload, token, tokenHash) {
const viewerUrl = AcaiHttpClient.buildViewerUrl(target, `action_ws=cmsApi&subaction=${action}`);
try {
console.error(`[AcaiHttpClient] postCmsApi - START: ${action} on ${viewerUrl}`);
console.error(`[AcaiHttpClient] Payload:`, JSON.stringify(payload).substring(0, 500));
console.error(`[AcaiHttpClient] Token: ${token ? '****' + token.slice(-4) : 'No token provided'}`);
payload["token"] = token;
payload["tokenHash"] = tokenHash;
const response = await axios.post(viewerUrl, payload, {
headers: AcaiHttpClient.buildViewerHeaders(target, {
"X-Acai-Token": token
}),
timeout: 30000
});
console.error(`[AcaiHttpClient] postCmsApi - SUCCESS: ${action} (${response.status})`);
return response;
} catch (error) {
console.error(`[AcaiHttpClient] postCmsApi - ERROR: ${action} on ${viewerUrl} - ${error.message}`);
if (error.response) {
console.error(`[AcaiHttpClient] Response status: ${error.response.status}`);
console.error(`[AcaiHttpClient] Response data:`, error.response.data?.substring ? error.response.data.substring(0, 200) : error.response.data);
} else if (error.code) {
console.error(`[AcaiHttpClient] Error code: ${error.code}`);
}
throw error;
}
}
/**
* POST a viewer_functions para checkModuleCode
* @param {string} web_url - URL base del sitio (ej: http://localhost:PORT)
* @param {string} token - Token Acai
* @param {Object} data - {moduleName, vars}
* @returns {Promise<Object>} Respuesta del servidor
*/
static async checkModuleCode(target, token, data) {
const viewerUrl = AcaiHttpClient.buildViewerUrl(target, "action_ws=checkModuleCode");
try {
data["token"] = token;
console.error(`[AcaiHttpClient] checkModuleCode - START: ${viewerUrl}`);
const response = await axios.post(viewerUrl, data, {
headers: AcaiHttpClient.buildViewerHeaders(target),
timeout: 30000
});
console.error(`[AcaiHttpClient] checkModuleCode - SUCCESS: ${viewerUrl} (${response.status})`);
return response;
} catch (error) {
console.error(`[AcaiHttpClient] checkModuleCode - ERROR: ${viewerUrl}`, error.message);
throw error;
}
}
/**
* POST a viewer_functions para addModuleToRecord
* @param {string} web_url - URL base del sitio (ej: http://localhost:PORT)
* @param {string} token - Token Acai
* @param {Object} data - {moduleName, vars}
* @returns {Promise<Object>} Respuesta del servidor
*/
static async addModuleToRecord(target, token, tokenHash, data) {
const viewerUrl = AcaiHttpClient.buildViewerUrl(target, "action_ws=addModuleToRecord");
try {
data["token"] = token;
data["tokenHash"] = tokenHash;
console.error(`[AcaiHttpClient] addModuleToRecord - START: ${viewerUrl}`);
const response = await axios.post(viewerUrl, data, {
headers: AcaiHttpClient.buildViewerHeaders(target),
timeout: 30000
});
console.error(`[AcaiHttpClient] addModuleToRecord - SUCCESS: ${viewerUrl} (${response.status})`);
return response;
} catch (error) {
console.error(`[AcaiHttpClient] addModuleToRecord - ERROR: ${viewerUrl}`, error.message);
throw error;
}
}
/**
* POST a mcp_respond.php para setModuleConfigVars
* @param {string} web_url - URL base del sitio (ej: http://localhost:PORT)
* @param {string} token - Token Acai
* @param {string} tokenHash - Token hash Acai
* @param {Object} data - {tableName, recordNum, sectionId, vars}
* @returns {Promise<Object>} Respuesta del servidor
*/
static async setModuleConfigVars(target, token, tokenHash, data) {
const url = AcaiHttpClient.buildViewerUrl(target, "action_ws=setModuleConfigVars");
try {
data["token"] = token;
data["tokenHash"] = tokenHash;
console.error(`[AcaiHttpClient] setModuleConfigVars - START: ${url}`);
const response = await axios.post(url, data, {
headers: AcaiHttpClient.buildViewerHeaders(target),
timeout: 30000
});
console.error(`[AcaiHttpClient] setModuleConfigVars - SUCCESS: ${url} (${response.status})`);
return response;
} catch (error) {
console.error(`[AcaiHttpClient] setModuleConfigVars - ERROR: ${url}`, error.message);
throw error;
}
}
/**
* POST a viewer_functions para getModuleConfigVars
* @param {string} web_url - URL base del sitio (ej: http://localhost:PORT)
* @param {string} token - Token Acai
* @param {string} tokenHash - Token hash Acai
* @param {Object} data - {tableName, recordNum, sectionId}
* @returns {Promise<Object>} Respuesta del servidor
*/
static async getModuleConfigVars(target, token, tokenHash, data) {
const url = AcaiHttpClient.buildViewerUrl(target, "action_ws=getModuleConfigVars");
try {
data["token"] = token;
data["tokenHash"] = tokenHash;
console.error(`[AcaiHttpClient] getModuleConfigVars - START: ${url}`);
const response = await axios.post(url, data, {
headers: AcaiHttpClient.buildViewerHeaders(target),
timeout: 30000
});
console.error(`[AcaiHttpClient] getModuleConfigVars - SUCCESS: ${url} (${response.status})`);
return response;
} catch (error) {
console.error(`[AcaiHttpClient] getModuleConfigVars - ERROR: ${url}`, error.message);
throw error;
}
}
/**
* POST para subir imagen a campo de registro
* @param {string} website - Website/domain (no usado, se usa CMS_URL del config)
* @param {string} tableName - Nombre de la tabla
* @param {string} recordId - ID del registro
* @param {string} fieldName - Nombre del campo
* @param {FormData} formData - Datos del archivo
* @param {string} token - Token Acai
* @returns {Promise<Object>} Respuesta del servidor
*/
static async uploadRecordImage(website, tableName, recordId, fieldName, formData, token) {
const uploadUrl = `${CMS_URL}/lib/menus/modals/plupload/multiupload/upload.php?menu=${tableName}&fieldName=${fieldName}&num=${recordId}&preSaveTempId=`;
return axios.post(uploadUrl, formData, {
headers: {
...formData.getHeaders(),
"X-Acai-Token": token
}
});
}
/**
* POST a SaaS API para guardar esquema
* @param {Object} payload - {action, type, schema, dir, id, ...}
* @param {string} token - Token autenticación
* @returns {Promise<Object>} Respuesta del servidor
*/
static async saasPostRequest(payload, token) {
const SAAS_URL = 'https://ws.cocosolution.com/api/schemas/';
try {
console.error(`[AcaiHttpClient] saasPostRequest - START: ${SAAS_URL} (action: ${payload.action})`);
const response = await axios.post(SAAS_URL, payload, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
timeout: 30000
});
console.error(`[AcaiHttpClient] saasPostRequest - SUCCESS: ${SAAS_URL} (${response.status})`);
return response;
} catch (error) {
console.error(`[AcaiHttpClient] saasPostRequest - ERROR: ${SAAS_URL} - ${error.message}`);
if (error.response) {
console.error(`[AcaiHttpClient] Response status: ${error.response.status}`);
console.error(`[AcaiHttpClient] Response data:`, error.response.data?.substring ? error.response.data.substring(0, 200) : error.response.data);
} else if (error.code) {
console.error(`[AcaiHttpClient] Error code: ${error.code}`);
}
throw error;
}
}
/**
* PUT a SaaS API para actualizar esquema
* @param {Object} payload - {action, type, schema, dir, id}
* @param {string} token - Token autenticación
* @returns {Promise<Object>} Respuesta del servidor
*/
static async saasPutRequest(payload, token) {
const SAAS_URL = 'https://ws.cocosolution.com/api/schemas/';
try {
console.error(`[AcaiHttpClient] saasPutRequest - START: ${SAAS_URL} (action: ${payload.action})`);
const response = await axios.put(SAAS_URL, payload, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
timeout: 30000
});
console.error(`[AcaiHttpClient] saasPutRequest - SUCCESS: ${SAAS_URL} (${response.status})`);
return response;
} catch (error) {
console.error(`[AcaiHttpClient] saasPutRequest - ERROR: ${SAAS_URL} - ${error.message}`);
if (error.response) {
console.error(`[AcaiHttpClient] Response status: ${error.response.status}`);
console.error(`[AcaiHttpClient] Response data:`, error.response.data?.substring ? error.response.data.substring(0, 200) : error.response.data);
} else if (error.code) {
console.error(`[AcaiHttpClient] Error code: ${error.code}`);
}
throw error;
}
}
}
/**
* Helper para construir parámetros comunes de formulario
*/
export class FormParamsBuilder {
static buildRecordSaveParams(tableName, recordId, fields, enlace) {
const params = new URLSearchParams();
params.append('menu', tableName);
params.append('_defaultAction', 'save');
params.append('num', recordId ? String(recordId) : '');
params.append('type', '');
params.append('preSaveTempId', Date.now().toString());
params.append('action=save', 'Guardar');
// Agregar todos los campos
for (const [fieldName, value] of Object.entries(fields)) {
if (fieldName === 'enlace') continue;
if (value !== null && value !== undefined) {
const strValue = String(value);
params.append(fieldName, strValue);
// Detectar y descomponer fechas
const dateRegex = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;
const match = strValue.match(dateRegex);
if (match) {
params.append(`${fieldName}:year`, match[1]);
params.append(`${fieldName}:mon`, match[2]);
params.append(`${fieldName}:day`, match[3]);
params.append(`${fieldName}:hour24`, match[4]);
params.append(`${fieldName}:min`, match[5]);
}
}
}
params.append('enlace', enlace);
return params;
}
static buildDeleteRecordsParams(tableName, recordIds) {
const params = new URLSearchParams();
params.append('menu', tableName);
params.append('_defaultAction', 'list');
params.append('page', '1');
params.append('_advancedAction', 'eraseRecords');
params.append('_advancedActionSubmit', 'Ejecutar');
recordIds.forEach(id => {
params.append('selectedRecords[]', String(id));
});
return params;
}
static buildTableCreateParams(menuName, tableName, type, enlace, seo_metas, menuOrder) {
return new URLSearchParams({
menu: "database",
_defaultAction: "addTable_save",
type: type,
preset: "",
enlace: enlace ? "on" : "",
seo_metas: seo_metas ? "on" : "",
menuName: menuName,
menuOrder: menuOrder.toString(),
tableName: tableName
});
}
static buildTableDeleteParams(tableName) {
const params = new URLSearchParams();
params.append('menu', 'database');
params.append('action', 'editTable');
params.append('dropTable', '1');
params.append('tableName', tableName);
return params;
}
static buildFieldEditParams(tableName, multipleFields) {
const params = new URLSearchParams();
params.append('menu', 'database');
params.append('_defaultAction', 'editTable');
params.append('editField', '1');
params.append('tableName', tableName);
params.append('save', '1');
params.append('multipleFields', JSON.stringify(multipleFields));
return params;
}
static buildFieldDeleteParams(tableName, fieldname) {
const params = new URLSearchParams();
params.append('menu', 'database');
params.append('action', 'editTable');
params.append('editField', '1');
params.append('tableName', tableName);
params.append('fieldname', fieldname);
params.append('deleteField', '1');
return params;
}
}
/**
* Helper para construir URLs de query
*/
export class QueryParamsBuilder {
static buildListRecordsQuery(tableName, page, keyword) {
const params = new URLSearchParams({
menu: tableName,
json: "1"
});
if (page) params.append("page", String(page));
if (keyword) params.append("keyword", keyword);
return params;
}
static buildListUploadsQuery(tableName, recordId, fieldName) {
return new URLSearchParams({
menu: tableName,
action: 'uploadList',
fieldName: fieldName,
num: recordId,
preSaveTempId: '',
json: '1'
});
}
static buildDeleteUploadQuery(tableName, recordId, fieldName, uploadId) {
return new URLSearchParams({
menu: tableName,
action: 'uploadErase',
fieldName: fieldName,
uploadNum: uploadId,
num: recordId,
preSaveTempId: ''
});
}
}
export default AcaiHttpClient;

View File

@@ -0,0 +1,6 @@
/**
* Auth parameters helper.
* In stdio mode, credentials come from environment variables — no inline params needed.
* withAuthParams just passes through the schema unchanged.
*/
export const withAuthParams = (schema) => schema;

View File

@@ -0,0 +1,215 @@
/**
* Centralized error handling for tools
* Provides consistent error responses and logging
*/
/**
* Handle and format tool errors
* @param {Error|string} error - The error object or message
* @param {string} context - Context where the error occurred (e.g., "save_module", "create_record")
* @param {Object} additionalInfo - Additional information to include in response
* @returns {Object} Formatted error response
*/
export function handleToolError(error, context = "unknown", additionalInfo = {}) {
// Log error to console
console.error(`[Tool Error - ${context}]`, error instanceof Error ? error.message : error);
if (error instanceof Error && error.stack) {
console.error(`Stack:`, error.stack);
}
// Extract error message
let errorMessage = error instanceof Error ? error.message : String(error);
let errorCode = "UNKNOWN_ERROR";
let statusCode = 500;
// Handle specific error types
if (error.response) {
// Axios error with response
statusCode = error.response.status || 500;
errorMessage = error.response.data?.message ||
error.response.data?.error ||
errorMessage;
errorCode = `HTTP_${statusCode}`;
} else if (error.code) {
// Error with code (like ENOTFOUND, ECONNREFUSED, etc.)
errorCode = error.code;
errorMessage = `${error.code}: ${errorMessage}`;
}
// Return formatted error response
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: {
code: errorCode,
message: errorMessage,
context: context,
...additionalInfo
}
}, null, 2)
}],
isError: true
};
}
/**
* Handle API response errors (when response contains error indication)
* @param {Object} data - Response data from API
* @param {string} context - Context where error occurred
* @returns {Object|null} Error response or null if no error
*/
export function handleApiResponse(data, context = "unknown") {
// Check for common error patterns in Acai CMS responses
/*if (!data) {
return handleToolError("Empty response from API", context, { details: "API returned null or undefined" });
}*/
// PHP/Acai error responses typically have error field or PHPSyntax errors
if (data.error || data.Error) {
return handleToolError(data.error || data.Error, context, { details: data });
}
if (data.PHPSyntax) {
return handleToolError(`PHP Syntax Error: ${data.PHPSyntax}`, context, { details: data });
}
// If it's a string response with error indicators
if (typeof data === 'string' && data.trim().length > 0) {
// Check for common error patterns
if (data.toLowerCase().includes('error') ||
data.toLowerCase().includes('fatal') ||
data.toLowerCase().includes('undefined') ||
data.toLowerCase().includes('syntax')) {
return handleToolError(data, context, { details: "API returned error string" });
}
}
// If success field exists and is false
if (data.success === false) {
return handleToolError(data.message || "API returned success: false", context, { details: data });
}
// No error detected
return null;
}
/**
* Validate required parameters
* @param {Object} params - Parameters object
* @param {string[]} requiredFields - Array of required field names
* @param {string} context - Context where validation occurs
* @returns {Object|null} Error response or null if all valid
*/
export function validateRequired(params, requiredFields, context = "unknown") {
const missingFields = [];
requiredFields.forEach(field => {
const value = params[field];
if (value === null || value === undefined ||
(typeof value === 'string' && value.trim() === '')) {
missingFields.push(field);
}
});
if (missingFields.length > 0) {
return handleToolError(
`Missing required parameters: ${missingFields.join(', ')}`,
context,
{ requiredFields, missingFields }
);
}
return null;
}
/**
* Validate parameter types
* @param {Object} params - Parameters object
* @param {Object} schema - Schema of expected types {fieldName: 'string'|'number'|'boolean'|'array'|'object'}
* @param {string} context - Context where validation occurs
* @returns {Object|null} Error response or null if all valid
*/
export function validateTypes(params, schema, context = "unknown") {
const typeErrors = [];
for (const [field, expectedType] of Object.entries(schema)) {
const value = params[field];
if (value === null || value === undefined) {
continue; // Skip optional fields that are not provided
}
let actualType = typeof value;
if (Array.isArray(value)) actualType = 'array';
if (value instanceof Date) actualType = 'date';
if (actualType !== expectedType) {
typeErrors.push(`${field}: expected ${expectedType}, got ${actualType}`);
}
}
if (typeErrors.length > 0) {
return handleToolError(
`Type validation failed: ${typeErrors.join('; ')}`,
context,
{ typeErrors }
);
}
return null;
}
/**
* Safely parse JSON with error handling
* @param {string} jsonString - JSON string to parse
* @param {string} context - Context where parsing occurs
* @returns {Object} Parsed object or error response object
*/
export function safeJsonParse(jsonString, context = "unknown") {
try {
return { success: true, data: JSON.parse(jsonString) };
} catch (error) {
return {
success: false,
error: handleToolError(error, `${context} - JSON parsing`, { input: jsonString.substring(0, 100) })
};
}
}
/**
* Create a validation middleware for tools
* @param {string[]} requiredFields - Required parameter names
* @param {Object} typeSchema - Type validation schema
* @returns {Function} Middleware function
*/
export function createValidator(requiredFields = [], typeSchema = {}) {
return function validateInput(params, context = "unknown") {
// Check required fields
const requiredError = validateRequired(params, requiredFields, context);
if (requiredError) return requiredError;
// Check types
const typeError = validateTypes(params, typeSchema, context);
if (typeError) return typeError;
return null; // No errors
};
}
/**
* Wrap a tool handler with automatic error handling
* @param {Function} handler - The tool handler function
* @param {string} toolName - Name of the tool for logging
* @returns {Function} Wrapped handler
*/
export function withErrorHandling(handler, toolName = "unknown") {
return async (params, extra) => {
try {
return await handler(params, extra);
} catch (error) {
return handleToolError(error, toolName);
}
};
}

View File

@@ -0,0 +1,102 @@
import axios from "axios";
/**
* Helper to save files using saveFileBuilder action
* Used by multiple tools (save.js, saveGeneralSection.js, write.js, etc.)
*
* @param {Object} params
* @param {string} params.web_url - URL base del sitio (ej: http://localhost:PORT)
* @param {string} params.token - Session token
* @param {string} params.tokenHash - Token hash
* @param {string} params.path - Folder path (e.g., '/modulos/mymodule/')
* @param {string} params.fileName - File name (e.g., 'script.js', 'style.css')
* @param {string} params.content - File content
* @returns {Promise<Object>} Response from the API
*/
export async function saveFileBuilder({
web_url,
token,
tokenHash,
path,
fileName,
content,
rawDataSended = true
}) {
if (!content) {
return null;
}
const viewerUrl = web_url + '/cms/lib/viewer_functions.php';
const payload = {
action_ws: 'saveFileBuilder',
token: token,
tokenHash: tokenHash,
fileName: fileName,
content: content,
rawDataSended: rawDataSended,
rootFolder: false,
path: path
};
console.error(`[saveFileBuilder] URL: ${viewerUrl}`);
console.error(`[saveFileBuilder] Path: ${path}`);
console.error(`[saveFileBuilder] Content length: ${content.length} chars`);
try {
const response = await axios.post(viewerUrl, payload, {
headers: { "Content-Type": "application/json" }
});
console.error(`[saveFileBuilder] Response for ${fileName}:`, JSON.stringify(response.data, null, 2));
return {
success: response.data.success || false,
message: response.data.message || (response.data.success ? 'OK' : 'Error'),
data: response.data
};
} catch (error) {
console.error(`[saveFileBuilder] Error saving ${fileName}:`, error.message);
return {
success: false,
message: `Error saving ${fileName}: ${error.message}`,
error: error.message
};
}
}
/**
* Helper to save multiple files at once
*
* @param {Object} params
* @param {string} params.web_url - URL base del sitio (ej: http://localhost:PORT)
* @param {string} params.token - Session token
* @param {string} params.tokenHash - Token hash
* @param {string} params.path - Folder path (e.g., '/modulos/mymodule/')
* @param {Object} params.files - Object with fileName: content pairs
* @returns {Promise<Object>} Results for each file
*/
export async function saveMultipleFiles({
web_url,
token,
tokenHash,
path,
files
}) {
const results = {};
for (const [fileName, content] of Object.entries(files)) {
if (content) {
results[fileName] = await saveFileBuilder({
web_url,
token,
tokenHash,
path,
fileName,
content
});
}
}
return results;
}

24
mcp-server/tools/index.js Normal file
View File

@@ -0,0 +1,24 @@
import { registerModuleTools } from './modules/index.js';
import { registerTableTools } from './tables/index.js';
import { registerRecordTools } from './records/index.js';
import { registerMediaTools } from './media/index.js';
import { registerAuthTools } from './auth/index.js';
import { registerRemoteGitTools } from './remote_git/index.js';
import { registerNavigationTools } from './navigation/index.js';
import { registerProjectTools } from './project/index.js';
import { registerFileTools } from './files/index.js';
/**
* Register all tools on the MCP server
*/
export function registerTools(server) {
registerModuleTools(server);
registerTableTools(server);
registerRecordTools(server);
registerMediaTools(server);
registerAuthTools(server);
registerRemoteGitTools(server);
registerNavigationTools(server);
registerProjectTools(server);
registerFileTools(server);
}

View File

@@ -0,0 +1,213 @@
import { z } from "zod";
import axios from "axios";
import fs from "fs";
import path from "path";
import sharp from "sharp";
import { withAuth, getSessionCredentials } from "../../auth/index.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.js";
// --- Verificación de créditos y reporte de uso ---
const WS_BASE = "https://ws.cocosolution.com/api/handler_acaicode.php";
// Precios Gemini 2.5 Flash: input $0.15/1M tokens, output $0.60/1M tokens
function calcCost(usageMetadata) {
const input = usageMetadata?.promptTokenCount || 0;
const output = usageMetadata?.candidatesTokenCount || 0;
return Math.round(((input * 0.15 + output * 0.60) / 1_000_000) * 1e6) / 1e6;
}
function getAcaiToken() {
const projectDir = process.env.ACAI_PROJECT_DIR || "";
if (!projectDir) return null;
try {
const acaiFile = path.join(projectDir, ".acai");
const data = JSON.parse(fs.readFileSync(acaiFile, "utf-8"));
return data.token || null;
} catch { return null; }
}
async function checkCredits() {
const token = getAcaiToken();
if (!token) return false; // Si no hay token, no bloquear
const testParam = process.env.STRIPE_MODE === "test" ? "&test" : "";
try {
const resp = await axios.put(`${WS_BASE}?action=getUsageLimits${testParam}`, {}, {
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
timeout: 10000,
});
return resp.data?.data?.exceeded === true;
} catch { return false; }
}
function reportImageUsage(usageMetadata, model) {
const token = getAcaiToken();
if (!token) return;
const testParam = process.env.STRIPE_MODE === "test" ? "&test" : "";
const cost = calcCost(usageMetadata);
const payload = {
action: "reportUsage",
model: model || "gemini-2.5-flash-image",
input_tokens: usageMetadata?.promptTokenCount || 0,
output_tokens: usageMetadata?.candidatesTokenCount || 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
cost_usd: cost,
session_id: "",
};
// Fire and forget
axios.put(`${WS_BASE}?action=reportUsage${testParam}`, payload, {
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
timeout: 10000,
}).then(resp => {
if (resp.data?.success) console.error(`[generate_image] Usage reported: ${model} cost=$${cost}`);
else console.error(`[generate_image] Usage report failed:`, resp.data);
}).catch(err => console.error(`[generate_image] Usage report error:`, err.message));
}
export function registerGenerateImageTool(server) {
server.tool(
"generate_image",
`Generate an AI image and save it to the project's uploads folder. Returns preview URLs plus the recommended upload URL for upload_record_image. In Forge environments, prefer uploadUrl (or fullUrl if uploadUrl is absent) over dockerUrl when assigning the image to a record field.`,
withAuthParams({
prompt: z.string().describe("Description of the image to generate"),
width: z.number().optional().describe("Image width in pixels (default: 1024)"),
height: z.number().optional().describe("Image height in pixels (default: 1024)"),
style: z.string().optional().describe("Image style hint to add to prompt (e.g., 'photographic', 'digital-art', 'minimalist')"),
fileName: z.string().optional().describe("Custom filename (without extension). If not provided, auto-generated."),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ prompt, width = 1024, height = 1024, style, fileName }, extra) => {
try {
const nanoBananaApiKey = process.env.NANO_BANANA_API_KEY;
if (!nanoBananaApiKey) {
return {
content: [{ type: "text", text: "Error: NANO_BANANA_API_KEY not set." }],
isError: true,
};
}
const projectDir = process.env.ACAI_PROJECT_DIR || "";
if (!projectDir) {
return {
content: [{ type: "text", text: "Error: ACAI_PROJECT_DIR not set." }],
isError: true,
};
}
// Verificar créditos antes de generar
const exceeded = await checkCredits();
if (exceeded) {
return {
content: [{ type: "text", text: "Error: No te quedan créditos. Mejora tu plan para seguir usando el asistente." }],
isError: true,
};
}
// Build prompt with style hint
const fullPrompt = style ? `${prompt}. Style: ${style}` : prompt;
// Generate image via Google Gemini
const geminiModel = process.env.NANO_BANANA_MODEL || "gemini-2.5-flash-image";
const apiUrl = process.env.NANO_BANANA_URL ||
`https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent`;
const generateResponse = await axios.post(
apiUrl,
{
contents: [{ parts: [{ text: fullPrompt }] }],
generationConfig: {
responseModalities: ["TEXT", "IMAGE"],
},
},
{
headers: {
"x-goog-api-key": nanoBananaApiKey,
"Content-Type": "application/json",
},
timeout: 120000,
validateStatus: (status) => status < 500,
}
);
// Extract image from response
let imageBuffer = null;
if (generateResponse.data.candidates?.[0]?.content?.parts) {
for (const part of generateResponse.data.candidates[0].content.parts) {
if (part.inlineData?.data) {
imageBuffer = Buffer.from(part.inlineData.data, "base64");
break;
}
if (part.text?.startsWith("data:image")) {
const match = part.text.match(/data:image\/[^;]+;base64,(.+)/);
if (match) {
imageBuffer = Buffer.from(match[1], "base64");
break;
}
}
}
}
if (!imageBuffer) {
return {
content: [{
type: "text",
text: `Error: Could not extract image from API response. Status: ${generateResponse.status}. Response: ${JSON.stringify(generateResponse.data).substring(0, 1000)}`
}],
isError: true,
};
}
// Compress to JPEG
const originalSize = imageBuffer.length;
try {
imageBuffer = await sharp(imageBuffer)
.jpeg({ quality: 85 })
.toBuffer();
console.error(`[generate_image] Compressed: ${Math.round(originalSize / 1024)}KB → ${Math.round(imageBuffer.length / 1024)}KB`);
} catch (e) {
console.error(`[generate_image] Compression failed, using original:`, e.message);
}
// Save to cms/uploads/generated/
const uploadsDir = path.join(projectDir, "cms", "uploads", "generated");
fs.mkdirSync(uploadsDir, { recursive: true });
const safeName = fileName
? fileName.replace(/[^\w\-]/g, "_") + ".jpg"
: `generated-${Date.now()}.jpg`;
const filePath = path.join(uploadsDir, safeName);
fs.writeFileSync(filePath, imageBuffer);
const relativePath = `cms/uploads/generated/${safeName}`;
const dockerUrl = `http://localhost/${relativePath}`;
// Reportar uso (fire and forget)
reportImageUsage(generateResponse.data.usageMetadata, geminiModel);
const credentials = await getSessionCredentials(extra.sessionId);
const fullUrl = credentials.web_url ? `${credentials.web_url}/${relativePath}` : dockerUrl;
const uploadUrl = credentials.web_url ? fullUrl : dockerUrl;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
prompt: fullPrompt,
fileName: safeName,
filePath,
relativePath,
dockerUrl,
fullUrl,
uploadUrl,
size: `${Math.round(imageBuffer.length / 1024)}KB`,
note: `Image saved. To assign it with upload_record_image, use imageUrl="${uploadUrl}". dockerUrl is mainly for local preview/debugging.`,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "generate_image", { prompt });
}
})
);
}

View File

@@ -0,0 +1,9 @@
import { registerUploadRecordImageTool } from './upload.js';
import { registerUploadImageToAssetsTool } from './uploadImageToAssets.js';
import { registerGenerateImageTool } from './generateImage.js';
export function registerMediaTools(server) {
registerUploadRecordImageTool(server);
registerUploadImageToAssetsTool(server);
registerGenerateImageTool(server);
}

View File

@@ -0,0 +1,294 @@
import { z } from "zod";
import { withAuth, getSessionCredentials } from "../../auth/index.js";
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
/**
* Helper: POST to mcp_respond.php via viewer_functions.php
*/
async function mcpPost(target, actionWs, payload, token, tokenHash) {
return AcaiHttpClient.postViewerAction(
target,
actionWs,
payload,
token,
tokenHash,
{},
60000
);
}
export function registerUploadRecordImageTool(server) {
server.tool(
"upload_record_image",
"Upload an image to a specific record field in Acai CMS. Downloads the image from a URL and uploads it. Table names are WITHOUT the 'cms_' prefix. The recordId is the 'num' primary key, never 'id'. If the URL came from generate_image, prefer uploadUrl (or fullUrl) over dockerUrl in Forge environments.",
withAuthParams({
tableName: z.string().describe("Table name without 'cms_' prefix (e.g., 'productos')"),
recordId: z.string().describe("Record 'num' (primary key)"),
fieldName: z.string().describe("Field name (e.g., 'galeria_imagenes')"),
imageUrl: z.string().describe("URL of the image to upload"),
alt: z.string().optional().describe("Alt text for the image (optional)"),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ tableName, recordId, fieldName, imageUrl, alt = "" }, extra) => {
try {
const validationError = validateRequired(
{ tableName, recordId, fieldName, imageUrl },
['tableName', 'recordId', 'fieldName', 'imageUrl'],
'upload_record_image'
);
if (validationError) return validationError;
const credentials = await getSessionCredentials(extra.sessionId);
// Upload via mcp_respond.php uploadRecordImage (sends imageUrl, PHP downloads it)
const response = await mcpPost(
credentials,
"uploadRecordImage",
{ tableName, recordId, fieldName, imageUrl, alt },
credentials.token,
credentials.tokenHash
);
const apiError = handleApiResponse(response.data, 'upload_record_image');
if (apiError) return apiError;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: "Image uploaded successfully",
tableName,
recordId,
fieldName,
...response.data
}, null, 2)
}],
};
} catch (error) {
return handleToolError(error, 'upload_record_image', { tableName, recordId, fieldName });
}
})
);
server.tool(
"list_record_uploads",
"List all uploaded files in a specific upload field of a record. Table names are WITHOUT the 'cms_' prefix. The recordId is the 'num' primary key, never 'id'.",
withAuthParams({
tableName: z.string().describe("Table name without 'cms_' prefix (e.g., 'noticias')"),
recordId: z.string().describe("Record 'num' (primary key)"),
fieldName: z.string().describe("Upload field name (e.g., 'imagen_destacada')"),
}),
{ readOnlyHint: true, destructiveHint: false },
withAuth(async ({ tableName, recordId, fieldName }, extra) => {
try {
const validationError = validateRequired(
{ tableName, recordId, fieldName },
['tableName', 'recordId', 'fieldName'],
'list_record_uploads'
);
if (validationError) return validationError;
const credentials = await getSessionCredentials(extra.sessionId);
const response = await mcpPost(
credentials,
"listRecordUploads",
{ tableName, recordId, fieldName },
credentials.token,
credentials.tokenHash
);
const apiError = handleApiResponse(response.data, 'list_record_uploads');
if (apiError) return apiError;
const uploads = (response.data.data || []).map(upload => ({
uploadId: upload.num,
filePath: upload.filePath,
urlPath: upload.urlPath,
fileName: (upload.filePath || "").split('/').pop(),
altText: upload.info1 || upload.alt || "",
width: upload.width,
height: upload.height,
filesize: upload.filesize,
createdTime: upload.createdTime,
order: upload.order
}));
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
tableName,
recordId,
fieldName,
uploadsCount: uploads.length,
uploads,
note: "Use uploadId (num field) to replace or delete a specific file"
}, null, 2)
}],
};
} catch (error) {
return handleToolError(error, 'list_record_uploads', { tableName, recordId, fieldName });
}
})
);
server.tool(
"replace_record_image",
"Replace an existing image in an upload field. Downloads a new image from URL and replaces the specified upload. Use list_record_uploads to get the uploadId first. Table names are WITHOUT the 'cms_' prefix.",
withAuthParams({
tableName: z.string().describe("Table name without 'cms_' prefix"),
recordId: z.string().describe("Record 'num' (primary key)"),
fieldName: z.string().describe("Upload field name"),
uploadId: z.string().describe("Upload ID to replace (get from list_record_uploads)"),
imageUrl: z.string().describe("URL of the new image to upload"),
alt: z.string().optional().describe("Alt text for the image (optional)"),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ tableName, recordId, fieldName, uploadId, imageUrl, alt = "" }, extra) => {
try {
const validationError = validateRequired(
{ tableName, recordId, fieldName, uploadId, imageUrl },
['tableName', 'recordId', 'fieldName', 'uploadId', 'imageUrl'],
'replace_record_image'
);
if (validationError) return validationError;
const credentials = await getSessionCredentials(extra.sessionId);
// Step 1: Delete old upload
await mcpPost(
credentials,
"deleteRecordUpload",
{ uploadId },
credentials.token,
credentials.tokenHash
);
// Step 2: Upload new image
const response = await mcpPost(
credentials,
"uploadRecordImage",
{ tableName, recordId, fieldName, imageUrl, alt },
credentials.token,
credentials.tokenHash
);
const apiError = handleApiResponse(response.data, 'replace_record_image');
if (apiError) return apiError;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: "Image replaced successfully",
tableName,
recordId,
fieldName,
replacedUploadId: uploadId,
...response.data
}, null, 2)
}],
};
} catch (error) {
return handleToolError(error, 'replace_record_image', { tableName, recordId, fieldName, uploadId });
}
})
);
server.tool(
"delete_record_upload",
"Delete an uploaded file from a record's upload field. Use list_record_uploads to get the uploadId first. Table names are WITHOUT the 'cms_' prefix.",
withAuthParams({
uploadId: z.string().describe("Upload ID to delete (get from list_record_uploads)"),
}),
{ readOnlyHint: false, destructiveHint: true },
withAuth(async ({ uploadId }, extra) => {
try {
const validationError = validateRequired(
{ uploadId },
['uploadId'],
'delete_record_upload'
);
if (validationError) return validationError;
const credentials = await getSessionCredentials(extra.sessionId);
const response = await mcpPost(
credentials,
"deleteRecordUpload",
{ uploadId },
credentials.token,
credentials.tokenHash
);
const apiError = handleApiResponse(response.data, 'delete_record_upload');
if (apiError) return apiError;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: "Upload deleted successfully",
uploadId,
...response.data
}, null, 2)
}],
};
} catch (error) {
return handleToolError(error, 'delete_record_upload', { uploadId });
}
})
);
server.tool(
"reorder_record_uploads",
"Reorder uploaded files in a record's upload field. Pass an array of upload IDs (num) in the desired order. Use list_record_uploads to get the current upload IDs first.",
withAuthParams({
uploadIds: z.array(z.union([z.string(), z.number()])).describe("Array of upload IDs (num field) in the desired display order"),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ uploadIds }, extra) => {
try {
const validationError = validateRequired(
{ uploadIds },
['uploadIds'],
'reorder_record_uploads'
);
if (validationError) return validationError;
const credentials = await getSessionCredentials(extra.sessionId);
const response = await mcpPost(
credentials,
"reorderRecordUploads",
{ uploadIds },
credentials.token,
credentials.tokenHash
);
const apiError = handleApiResponse(response.data, 'reorder_record_uploads');
if (apiError) return apiError;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: "Uploads reordered successfully",
...response.data
}, null, 2)
}],
};
} catch (error) {
return handleToolError(error, 'reorder_record_uploads', { uploadIds });
}
})
);
}

View File

@@ -0,0 +1,211 @@
import { z } from "zod";
import axios from "axios";
import sharp from "sharp";
import { withAuth, getSessionCredentials } from "../../auth/index.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { saveFileBuilder } from "../helpers/fileBuilder.js";
import { withAuthParams } from "../helpers/authSchema.js";
/**
* Upload an image to the website assets folder
* Accepts base64, data URI, or URL
* Optionally resizes/compresses the image
*/
export function registerUploadImageToAssetsTool(server) {
server.tool(
"upload_image_to_assets",
"Upload an image to website assets (/images/). Accepts: base64, data URI, or URL. Optional resize (maxWidth/maxHeight) and compression (quality). Returns public URL.",
withAuthParams({
image: z.string().describe("Image data: base64 string, data URI, or URL to download from"),
fileName: z.string().optional().describe("Custom filename (without extension). If not provided, auto-generated name will be used"),
path: z.string().optional().default("/images/").describe("Path within assets folder (default: '/images/')"),
// Resize options
maxWidth: z.number().optional().describe("Maximum width in pixels. Image will be resized proportionally if larger"),
maxHeight: z.number().optional().describe("Maximum height in pixels. Image will be resized proportionally if larger"),
quality: z.number().min(1).max(100).optional().default(85).describe("JPEG/WebP quality (1-100, default: 85)"),
format: z.enum(["png", "jpg", "webp"]).optional().default("png").describe("Output format (default: png)"),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({
image,
fileName,
path: assetsPath = "/images/",
maxWidth,
maxHeight,
quality = 85,
format = "png"
}, extra) => {
try {
let imageBuffer;
// Step 1: Get image buffer from various sources
if (image.startsWith('data:')) {
// Data URI format: data:image/png;base64,xxxxx
const match = image.match(/data:image\/[^;]+;base64,(.+)/);
if (match) {
imageBuffer = Buffer.from(match[1], 'base64');
} else {
return {
content: [{
type: "text",
text: "Error: Invalid data URI format. Expected: data:image/xxx;base64,..."
}],
isError: true,
};
}
} else if (image.startsWith('http://') || image.startsWith('https://')) {
// URL - download the image
try {
const response = await axios.get(image, {
responseType: 'arraybuffer',
timeout: 30000,
maxContentLength: 50 * 1024 * 1024 // 50MB max
});
imageBuffer = Buffer.from(response.data, 'binary');
} catch (downloadError) {
return {
content: [{
type: "text",
text: `Error downloading image from URL: ${downloadError.message}`
}],
isError: true,
};
}
} else {
// Assume it's raw base64
try {
imageBuffer = Buffer.from(image, 'base64');
} catch (base64Error) {
return {
content: [{
type: "text",
text: "Error: Could not parse image data. Provide base64, data URI, or URL"
}],
isError: true,
};
}
}
// Validate we have a buffer
if (!imageBuffer || imageBuffer.length === 0) {
return {
content: [{
type: "text",
text: "Error: No valid image data received"
}],
isError: true,
};
}
// Step 2: Process image with sharp (resize/compress)
let sharpInstance = sharp(imageBuffer);
// Get original metadata
const metadata = await sharpInstance.metadata();
const originalSize = imageBuffer.length;
// Resize if dimensions specified
if (maxWidth || maxHeight) {
sharpInstance = sharpInstance.resize({
width: maxWidth,
height: maxHeight,
fit: 'inside', // Maintain aspect ratio
withoutEnlargement: true // Don't upscale
});
}
// Convert to target format with quality
let outputBuffer;
let mimeType;
let extension;
switch (format) {
case 'jpg':
outputBuffer = await sharpInstance.jpeg({ quality }).toBuffer();
mimeType = 'image/jpeg';
extension = 'jpg';
break;
case 'webp':
outputBuffer = await sharpInstance.webp({ quality }).toBuffer();
mimeType = 'image/webp';
extension = 'webp';
break;
case 'png':
default:
outputBuffer = await sharpInstance.png({
compressionLevel: Math.floor((100 - quality) / 11) // 0-9 compression
}).toBuffer();
mimeType = 'image/png';
extension = 'png';
break;
}
// Step 3: Upload to assets
const base64Image = outputBuffer.toString('base64');
// Generate filename if not provided
const finalFileName = fileName
? fileName.replace(/\.(jpg|jpeg|png|webp|gif)$/i, '') + '.' + extension
: `uploaded-${Date.now()}.${extension}`;
// Get credentials
const credentials = await getSessionCredentials(extra.sessionId);
// Upload using saveFileBuilder
const uploadResult = await saveFileBuilder({
web_url: credentials.web_url,
token: credentials.token,
tokenHash: credentials.tokenHash,
path: assetsPath,
fileName: finalFileName,
content: base64Image,
rawDataSended: false
});
if (uploadResult && uploadResult.success) {
// Build the public URL for the uploaded image
const imageUrl = `${credentials.web_url}/template/estandar/images/${finalFileName}`;
// Get final metadata
const finalMetadata = await sharp(outputBuffer).metadata();
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
imageUrl: imageUrl,
fileName: finalFileName,
path: assetsPath,
format: format,
originalSize: originalSize,
finalSize: outputBuffer.length,
compressionRatio: ((1 - outputBuffer.length / originalSize) * 100).toFixed(1) + '%',
dimensions: {
original: { width: metadata.width, height: metadata.height },
final: { width: finalMetadata.width, height: finalMetadata.height }
},
message: `Image uploaded successfully. Use this URL: ${imageUrl}`
}, null, 2)
}],
};
} else {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: uploadResult?.message || "Unknown error uploading to assets"
}, null, 2)
}],
isError: true,
};
}
} catch (error) {
return handleToolError(error, 'upload_image_to_assets', { fileName, path: assetsPath });
}
})
);
}

View File

@@ -0,0 +1,78 @@
import { z } from "zod";
import { withAuth, getSessionCredentials } from "../../auth/index.js";
import { handleToolError, handleApiResponse, validateRequired } from "../helpers/errorHandler.js";
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
import { withAuthParams } from "../helpers/authSchema.js";
export function registerCheckModuleTool(server) {
server.tool(
"check_module",
"Preview how a module renders with sample data. Returns a preview (first 50 lines + summary) by default — use fullRender=true for complete output. Always shows errors in full.",
withAuthParams({
moduleName: z.string().describe("Module ID/name to check"),
vars: z.record(z.string(), z.any()).describe("Object with builder variable values. Keys should match the variable names from data-field-label (without spaces/special chars)"),
fullRender: z.boolean().optional().describe("If true, returns complete rendered HTML. Default: false (preview — first 50 lines + summary, saves tokens)."),
}),
{ readOnlyHint: true, destructiveHint: false },
withAuth(async ({ moduleName, vars, fullRender }, extra) => {
const startTime = Date.now();
console.error(`[Tool] check_module - START: moduleName=${moduleName}, varsCount=${Object.keys(vars || {}).length}, sessionId=${extra.sessionId}`);
try {
// Validate required parameters
const validationError = validateRequired(
{ moduleName, vars },
['moduleName', 'vars'],
'check_module'
);
if (validationError) {
console.error(`[Tool] check_module - VALIDATION ERROR: ${validationError.content[0].text}`);
return validationError;
}
const credentials = await getSessionCredentials(extra.sessionId);
const payload = {
moduleName: moduleName,
vars: vars
};
console.error(`[Tool] check_module - Calling AcaiHttpClient.checkModuleCode...`);
const response = await AcaiHttpClient.checkModuleCode(credentials, credentials.token, payload);
// Check for API errors in response
/*const apiError = handleApiResponse(response.data, 'check_module');
if (apiError) {
console.error(`[Tool] check_module - API ERROR: ${apiError.content[0].text}`);
return apiError;
}*/
const elapsedTime = Date.now() - startTime;
console.error(`[Tool] check_module - SUCCESS: completed in ${elapsedTime}ms`);
let outputText = `Module Preview for "${moduleName}":\n\n${JSON.stringify(response.data, null, 2)}`;
// Preview mode (default): truncate to first 50 lines + summary
if (!fullRender) {
const lines = outputText.split('\n');
const PREVIEW_LINES = 50;
if (lines.length > PREVIEW_LINES) {
const preview = lines.slice(0, PREVIEW_LINES).join('\n');
outputText = `${preview}\n\n--- PREVIEW: showing ${PREVIEW_LINES} of ${lines.length} lines. Use fullRender=true for complete output. ---`;
}
}
return {
content: [{
type: "text",
text: outputText
}],
};
} catch (error) {
const elapsedTime = Date.now() - startTime;
console.error(`[Tool] check_module - ERROR after ${elapsedTime}ms: ${error.message}`);
return handleToolError(error, 'check_module', { moduleName, varsCount: Object.keys(vars || {}).length });
}
})
);
}

View File

@@ -0,0 +1,62 @@
import { z } from "zod";
import { withAuth, getSessionCredentials, getApiClient } from "../../auth/index.js";
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
export function registerCheckModuleUsageTool(server) {
server.tool(
"check_module_usage",
"Check which pages/URLs use a module. Call BEFORE delete_module to verify it's safe to remove.",
withAuthParams({
id: z.string().describe("Module ID to check usage for"),
}),
{ readOnlyHint: true, destructiveHint: false },
withAuth(async ({ id }, extra) => {
try {
// Validate required parameters
const validationError = validateRequired({ id }, ['id'], 'check_module_usage');
if (validationError) return validationError;
const credentials = await getSessionCredentials(extra.sessionId);
// Build the request payload
const payload = {
action_ws: "checkModuleInWeb",
module: id,
token: credentials.token,
tokenHash: credentials.tokenHash
};
// Make the request to the client's website
const response = await AcaiHttpClient.postViewerFunctions(
await getApiClient(extra.sessionId),
payload
);
// Check for API errors in response
const apiError = handleApiResponse(response.data, 'check_module_usage');
if (apiError) return apiError;
// Extract usage information
const usageData = response.data.data || response.data;
return {
content: [{
type: "text", text: JSON.stringify({
success: true,
moduleId: id,
usage: usageData,
canDelete: !usageData || Object.keys(usageData).length === 0,
message: Object.keys(usageData || {}).length === 0
? "Module is not used anywhere - safe to delete"
: `Module is used in ${Object.keys(usageData || {}).length} location(s)`
}, null, 2)
}],
};
} catch (error) {
return handleToolError(error, 'check_module_usage', { id });
}
})
);
}

View File

@@ -0,0 +1,82 @@
import { z } from "zod";
import axios from "axios";
import path from "path";
import { withAuth, getSessionCredentials } from "../../auth/index.js";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { LOCAL_SERVER_URL } from "../../config/index.js";
export function registerCompileModuleTool(server) {
server.tool(
"compile_module",
`Manually recompile a module or general section when generated files may be out of sync and you need to force compilation without editing index-base.tpl.
Do not use this as part of the normal editing flow: most index-base.tpl edits made through the Acai file tools compile automatically.
This is a recovery / resync tool, not a required step after routine changes.
It parses the HTML into Twig, generates builder vars, and syncs with the Docker CMS.
Pass the full path to the index-base.tpl file and the project directory.`,
withAuthParams({
filePath: z.string().describe("Full absolute path to the index-base.tpl file that was edited"),
projectDir: z.string().describe("Full absolute path to the project root directory"),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ filePath, projectDir }, extra) => {
try {
const validationError = validateRequired(
{ filePath, projectDir },
['filePath', 'projectDir'],
'compile_module'
);
if (validationError) return validationError;
const normalizedProjectDir = path.resolve(projectDir);
const normalizedFilePath = path.resolve(filePath);
const projectSlug = path.basename(normalizedProjectDir);
const relativePath = path.relative(normalizedProjectDir, normalizedFilePath);
const canUseSlugMode =
!!projectSlug &&
!!relativePath &&
relativePath !== "" &&
!relativePath.startsWith("..") &&
!path.isAbsolute(relativePath);
const payload = canUseSlugMode
? { project: projectSlug, relativePath, project_dir: projectDir }
: { file: filePath, project_dir: projectDir };
// Call the Python server compile endpoint
const response = await axios.post(
`${LOCAL_SERVER_URL}/api/compile-module`,
payload,
{ headers: { "Content-Type": "application/json" }, timeout: 30000 }
);
if (response.data?.ok) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: "Module compiled successfully",
output: response.data.output || "",
}, null, 2)
}],
};
} else {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: response.data?.error || "Compilation failed",
}, null, 2)
}],
isError: true,
};
}
} catch (error) {
return handleToolError(error, 'compile_module', { filePath, projectDir });
}
})
);
}

View File

@@ -0,0 +1,75 @@
import { z } from "zod";
import axios from "axios";
import { withAuth } from "../../auth/index.js";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { LOCAL_SERVER_URL } from "../../config/index.js";
export function registerCreateModuleTool(server) {
server.tool(
"create_module",
`Create a new builder module in the project. This creates the module directory with index-base.tpl, style.css, and script.js, then compiles it automatically.
After creating the module, use add_module_to_record to place it on a page, then set_module_config_vars to fill its variables with content.
Parameters:
- moduleId: unique identifier (lowercase, underscores, e.g. "hero_banner")
- html: the Twig/HTML content for index-base.tpl
- css: optional CSS for style.css
- js: optional JavaScript for script.js
- php: optional PHP code for module hook file .php
- label: human-readable name (e.g. "Hero Banner V2")
- description: brief description of what the module does`,
withAuthParams({
moduleId: z.string().describe("Module identifier (lowercase, underscores, e.g. 'hero_banner')"),
html: z.string().describe("HTML/Twig content for index-base.tpl ( needed for compile module )"),
css: z.string().optional().default("").describe("CSS content for style.css ( optional, you can also add CSS later on file )"),
js: z.string().optional().default("").describe("JavaScript content for script.js ( optional, you can also add CSS later on file )"),
php: z.string().optional().default("").describe("PHP code for module hook file .php ( optional, you can also add CSS later on file )"),
label: z.string().optional().default("").describe("Human-readable module name"),
description: z.string().optional().default("").describe("Brief description"),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ moduleId, html, css, js, php, label, description }, extra) => {
try {
const validationError = validateRequired({ moduleId, html }, ['moduleId', 'html'], 'create_module');
if (validationError) return validationError;
const projectDir = process.env.ACAI_PROJECT_DIR || "";
if (!projectDir) {
return { content: [{ type: "text", text: "Error: ACAI_PROJECT_DIR not set" }], isError: true };
}
moduleId = moduleId.toLowerCase().replace(/\s+/g, '_'); // Ensure moduleId is lowercase and uses underscores
moduleId = moduleId + "_" + (Math.random().toString(36).substring(2, 8).toUpperCase());
const response = await axios.post(
`${LOCAL_SERVER_URL}/api/create-module`,
{ project_dir: projectDir, module_id: moduleId, html, css: css || "", js: js || "", label, description, php: php || "" },
{ headers: { "Content-Type": "application/json" }, timeout: 30000 }
);
if (response.data?.success) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
moduleId,
path: response.data.path,
compiled: response.data.compiled,
note: response.data.compiled
? "Module created and compiled. Use add_module_to_record to place it on a page, then set_module_config_vars to fill its variables."
: "Module created but compilation failed: " + (response.data.compile_output || "unknown error"),
}, null, 2),
}],
};
} else {
return { content: [{ type: "text", text: JSON.stringify(response.data) }], isError: true };
}
} catch (error) {
return handleToolError(error, 'create_module', { moduleId });
}
})
);
}

View File

@@ -0,0 +1,9 @@
import { registerCheckModuleTool } from './check.js';
import { registerCheckModuleUsageTool } from './checkUsage.js';
import { registerCompileModuleTool } from './compile.js';
export function registerModuleTools(server) {
registerCheckModuleTool(server);
registerCheckModuleUsageTool(server);
registerCompileModuleTool(server);
}

View File

@@ -0,0 +1,93 @@
import { z } from "zod";
import { withAuth, getSessionCredentials, getApiClient, getCommonParams } from "../../auth/index.js";
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.js";
export function registerSetModuleExampleDataTool(server) {
server.tool(
"set_module_example_data",
`Set example data for a module's editor preview. MANDATORY: call get_module first to get the schema, then fill EVERY variable.
Critical: uploads ALWAYS as [{urlPath: "..."}] (NEVER strings), multiv2 as array with 2+ items, var names from data-field-label (no spaces, lowercase). Use generate_image or placehold.co for image URLs.
See resource 'acai-cheat-sheet' → "Example Data Formatting" for type-specific value formats.`,
withAuthParams({
moduleId: z.string().describe("Module ID"),
moduleSchema: z.object({}).passthrough().describe("Complete module schema (obtained from get_module)"),
exampleData: z.object({}).passthrough().describe("Example data for EVERY variable in the module schema. Structure must match the schema exactly. Fill ALL variables without exception."),
}),
{ readOnlyHint: false, destructiveHint: false },
withAuth(async ({ moduleId, moduleSchema, exampleData }, extra) => {
try {
// Validate required parameters
const validationError = validateRequired({ moduleId, exampleData }, ['moduleId', 'exampleData'], 'set_module_example_data');
if (validationError) return validationError;
// Validate that all schema variables are present in exampleData
if (moduleSchema && moduleSchema.codeVars) {
const schemaVars = Object.keys(moduleSchema.codeVars);
const dataVars = Object.keys(exampleData);
const missingVars = schemaVars.filter(v => !dataVars.includes(v));
if (missingVars.length > 0) {
console.warn(`[set_module_example_data] WARNING: Missing variables in exampleData: ${missingVars.join(', ')}`);
}
// Check for upload fields that are not arrays
for (const [varName, varInfo] of Object.entries(moduleSchema.codeVars)) {
if (varInfo.type === 'upload' && exampleData[varName]) {
if (!Array.isArray(exampleData[varName])) {
console.error(`[set_module_example_data] ERROR: Upload field '${varName}' is not an array! Current value: ${JSON.stringify(exampleData[varName])}`);
console.error(`[set_module_example_data] Upload fields MUST be arrays with urlPath objects: [{"urlPath": "..."}]`);
} else if (exampleData[varName].length > 0 && !exampleData[varName][0].urlPath) {
console.error(`[set_module_example_data] ERROR: Upload field '${varName}' items missing 'urlPath' property!`);
}
}
}
}
const credentials = await getSessionCredentials(extra.sessionId);
const client = await getApiClient(extra.sessionId);
// Log data for debugging
console.error(`[set_module_example_data] Module ID: ${moduleId}`);
console.error(`[set_module_example_data] Module Schema:`, JSON.stringify(moduleSchema, null, 2));
console.error(`[set_module_example_data] Example Data:`, JSON.stringify(exampleData, null, 2));
// Prepare payload for setStaticVars action
const payload = await getCommonParams(extra.sessionId, {
action_ws: "setStaticVars",
moduleId: moduleId,
staticVars: exampleData,
schema: moduleSchema
});
console.error(`[set_module_example_data] Full Payload:`, JSON.stringify(payload, null, 2));
// Send to viewer_functions
const response = await client.post("/cms/lib/viewer_functions.php", payload);
console.error(`[set_module_example_data] Response:`, JSON.stringify(response.data, null, 2));
// Check for API errors in response
const apiError = handleApiResponse(response.data, 'set_module_example_data');
if (apiError) return apiError;
return {
content: [{
type: "text", text: JSON.stringify({
success: true,
message: `Example data set successfully for module '${moduleId}'`,
moduleId: moduleId,
dataCount: Object.keys(exampleData).length,
schemaVarsCount: moduleSchema?.codeVars ? Object.keys(moduleSchema.codeVars).length : 0,
response: response.data
}, null, 2)
}],
};
} catch (error) {
return handleToolError(error, 'set_module_example_data', { moduleId });
}
})
);
}

View File

@@ -0,0 +1,5 @@
import { registerNavigateBrowserTool } from './navigate.js';
export function registerNavigationTools(server) {
registerNavigateBrowserTool(server);
}

View File

@@ -0,0 +1,57 @@
import { z } from "zod";
import { withAuth, getSessionCredentials } from "../../auth/index.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { LOCAL_SERVER_URL } from "../../config/index.js";
import axios from "axios";
export function registerNavigateBrowserTool(server) {
server.tool(
"navigate_browser",
`Navigate the user's browser preview to a specific page URL. Use this after creating or modifying a page to show the result to the user. The enlace should be a path like "/servicios/" or "/blog/my-post/".`,
withAuthParams({
enlace: z.string().describe("The URL path to navigate to, e.g. '/servicios/' or '/contacto/'"),
}),
{ readOnlyHint: true, destructiveHint: false },
withAuth(async ({ enlace }, extra) => {
try {
if (!enlace) {
return {
content: [{ type: "text", text: "Error: enlace is required" }],
isError: true,
};
}
// Ensure enlace starts with /
if (!enlace.startsWith("/")) {
enlace = "/" + enlace;
}
const credentials = await getSessionCredentials(extra.sessionId);
const project = credentials.website || process.env.ACAI_WEBSITE || "";
// POST to Python server to set pending navigation
await axios.post(`${LOCAL_SERVER_URL}/api/browser/navigate`, {
project: project,
enlace: enlace,
}, {
headers: { "Content-Type": "application/json" },
timeout: 5000,
});
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
message: `Browser navigated to ${enlace}`,
enlace: enlace,
})
}],
};
} catch (error) {
return handleToolError(error, "navigate_browser", { enlace });
}
})
);
}

View File

@@ -0,0 +1,345 @@
/**
* Workflow auto-detection engine.
* Keyword-based pattern matching with weighted scoring + contextual adjustments.
* No LLM call needed — fast and deterministic.
*/
const WORKFLOW_PATTERNS = {
create_section: {
keywords: [
"crear seccion", "create section", "nueva seccion", "new section",
"anadir seccion", "add section", "crear tabla", "create table",
"nueva pagina", "new page", "nueva seccion web", "new web section",
"montar seccion", "set up section", "configurar seccion",
// Additional English patterns
"build section", "build page", "make section", "make page",
"set up page", "create page", "new table",
"section for", "seccion de", "seccion para",
// Natural phrasing
"want section", "need section", "quiero seccion",
"necesito seccion", "hacer seccion", "hacer pagina"
],
boost: [
"categoria", "category", "productos", "products", "blog", "noticias",
"news", "equipo", "team", "servicios", "services", "galeria", "gallery",
"portfolio", "testimonios", "testimonials", "faq", "preguntas",
"clientes", "clients", "proyectos", "projects",
"restaurante", "restaurant", "tienda", "store", "shop",
"eventos", "events", "cursos", "courses"
],
weight: 10
},
populate_content: {
keywords: [
"anadir contenido", "add content", "crear registros", "create records",
"poblar", "populate", "rellenar", "fill", "bulk", "masivo",
"insertar datos", "insert data", "meter datos", "cargar contenido",
"load content", "contenido de ejemplo", "sample content",
"crear entradas", "create entries", "anadir registros", "add records",
"registros de ejemplo", "sample records", "meter registros",
"fill with data", "fill with content", "add sample", "add examples",
"anadir ejemplos", "contenido de prueba", "test content"
],
boost: [
"imagenes", "images", "fotos", "photos", "stock", "ejemplo", "sample",
"demo", "placeholder", "varios", "multiple", "lote", "batch"
],
weight: 10
},
create_module: {
keywords: [
"crear modulo", "create module", "nuevo modulo", "new module",
"disenar modulo", "design module", "hacer modulo", "make module",
"componente", "component", "crear componente", "create component",
"nuevo componente", "new component", "montar modulo",
"build module", "build component", "make component"
],
boost: [
"hero", "slider", "card", "grid", "lista", "list", "banner",
"footer", "header", "navbar", "cta", "call to action",
"carousel", "accordion", "tabs", "pricing", "features"
],
weight: 10
},
edit_module: {
keywords: [
"editar modulo", "edit module", "modificar modulo", "modify module",
"cambiar modulo", "change module", "actualizar modulo", "update module",
"arreglar modulo", "fix module", "mejorar modulo", "improve module",
"corregir modulo", "ajustar modulo", "adjust module"
],
boost: [
"css", "html", "javascript", "js", "estilo", "style", "variable",
"campo", "field", "diseno", "design", "responsive", "movil", "mobile",
"color", "fuente", "font", "espaciado", "spacing",
"hero", "slider", "card", "grid", "banner", "footer", "header",
"navbar", "cta", "carousel", "accordion", "tabs", "pricing"
],
weight: 10
},
manage_records: {
keywords: [
"editar registro", "edit record", "actualizar registro", "update record",
"borrar registro", "delete record", "buscar registro", "search record",
"listar registros", "list records", "modificar registro", "modify record",
"ver registros", "view records", "consultar registros", "query records",
"cambiar datos", "change data", "eliminar registro", "remove record",
// CRUD-oriented English patterns
"update data", "delete data", "edit data", "modify data",
"update field", "change field", "edit entry", "delete entry",
"update price", "change price", "update name", "change name",
"remove records", "remove entries", "crud",
"insert record", "insert entry", "create record", "add entry",
"find record", "find records", "search records", "search data"
],
boost: [
"filtrar", "filter", "where", "campo", "field", "valor", "value",
"pagina", "page", "ordenar", "sort", "buscar", "search",
"precio", "price", "nombre", "name", "fecha", "date",
"estado", "status", "activo", "active"
],
weight: 8
},
manage_media: {
// Only specific action phrases — generic words like "image/foto" are in boost, not keywords
keywords: [
"subir imagen", "upload image", "subir foto", "upload photo",
"buscar imagen stock", "search stock image", "buscar fotos stock",
"generar imagen", "generate image", "generar foto",
"reemplazar imagen", "replace image", "cambiar imagen", "change image",
"borrar imagen", "delete image", "eliminar imagen", "remove image",
"gestionar media", "manage media", "gestionar imagenes", "manage images",
"buscar stock", "search stock", "stock photos", "fotos stock",
"subir archivo", "upload file"
],
boost: [
"stock", "pixabay", "pexels", "ai", "inteligencia artificial",
"resize", "thumbnail", "miniatura", "s3", "assets",
"comprimir", "compress", "optimizar", "optimize",
// Generic image words are boosts, NOT keywords
"imagen", "image", "foto", "photo", "galeria", "gallery", "media"
],
weight: 5 // Reduced from 8 — media is usually a step, not a workflow
},
seo_setup: {
keywords: [
"seo", "meta tags", "meta descripcion", "meta description",
"enlace", "slug", "url amigable", "friendly url", "sitemap",
"schema markup", "posicionamiento", "ranking",
"meta titulo", "meta title", "configurar seo", "setup seo",
"set up seo", "configure seo"
],
boost: [
"google", "keywords", "palabras clave", "busqueda", "search",
"indexar", "index", "robots", "canonical", "og:image"
],
weight: 6
},
explore_site: {
keywords: [
"explorar", "explore", "que tiene", "what's in", "listar todo",
"list all", "mostrar", "show me", "overview", "resumen",
"que hay", "que secciones", "what sections", "ver todo",
"show everything", "estructura", "structure", "inventario",
"mapa del sitio", "site map", "what modules", "que modulos"
],
boost: [
"estructura", "structure", "mapa", "map", "resumen", "summary",
"completo", "complete", "todas", "all"
],
weight: 5
}
};
/**
* Normalize text for matching: lowercase, remove accents, strip common articles, trim.
*/
function normalizeText(text) {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim();
}
/**
* Prepare task text for matching: normalize + strip common filler words (articles, prepositions)
* that break keyword matching (e.g., "editar el módulo" should match "editar módulo").
*/
function prepareTaskForMatching(text) {
const normalized = normalizeText(text);
// Strip common Spanish/English articles and short prepositions that break adjacent keyword matching
return normalized.replace(/\b(el|la|los|las|un|una|unos|unas|del|al|the|a|an)\b/g, " ").replace(/\s+/g, " ").trim();
}
// ── Contextual adjustment patterns ──────────────────────────────────────────
// These use regex word matching to detect intent combinations that substring
// matching misses (e.g., "create a new products section" has words separated).
const CREATION_VERBS = /\b(crear|create|nueva?o?|new|build|make|set up|montar|anadir|add|disenar|design|hacer)\b/;
const EDIT_VERBS = /\b(editar|edit|modificar|modify|cambiar|change|actualizar|update|arreglar|fix|mejorar|improve|ajustar|adjust|corregir)\b/;
const CRUD_VERBS = /\b(editar|edit|borrar|delete|eliminar|remove|actualizar|update|crear|create|insertar|insert|modificar|modify|buscar|search|listar|list|consultar|query|cambiar|change|find|get|ver|view)\b/;
const SECTION_WORDS = /\b(seccion|section|pagina|page|tabla|table|web|sitio|site)\b/;
const MODULE_WORDS = /\b(modulo|module|componente|component)\b/;
const RECORD_WORDS = /\b(registro|registros|record|records|datos|data|entrada|entradas|entry|entries|contenido|content|precio|price|campo|field)\b/;
const MEDIA_ONLY_WORDS = /\b(subir|upload|reemplazar|replace|descargar|download)\b/;
const IMAGE_WORDS = /\b(imagen|imagenes|image|images|foto|fotos|photo|photos|galeria|gallery)\b/;
// Words that indicate the task is about content/records, not creating a new section
const CONTENT_INTENT_WORDS = /\b(contenido|content|rellenar|fill|poblar|populate|registros|records|sample|ejemplo|articulos|articles|entradas|entries|anadir contenido|add content)\b/;
// Words that indicate the task is about SEO, not creating a new section
const SEO_INTENT_WORDS = /\b(seo|meta tags?|meta descripcion|meta description|meta titulo|meta title|sitemap|slug|posicionamiento|ranking|canonical)\b/;
/**
* Post-scoring contextual adjustments.
* Uses regex word matching (not substring) to detect intent patterns the keyword
* phase may miss due to non-adjacent words.
*/
function applyContextAdjustments(scores, normalizedTask) {
const hasCreationVerb = CREATION_VERBS.test(normalizedTask);
const hasEditVerb = EDIT_VERBS.test(normalizedTask);
const hasCrudVerb = CRUD_VERBS.test(normalizedTask);
const hasSection = SECTION_WORDS.test(normalizedTask);
const hasModule = MODULE_WORDS.test(normalizedTask);
const hasRecord = RECORD_WORDS.test(normalizedTask);
const hasMediaAction = MEDIA_ONLY_WORDS.test(normalizedTask);
const hasImageWord = IMAGE_WORDS.test(normalizedTask);
const hasContentIntent = CONTENT_INTENT_WORDS.test(normalizedTask);
const hasSeoIntent = SEO_INTENT_WORDS.test(normalizedTask);
// ── Section creation intent ──
// "create" + "section/page/table" = strong signal for create_section
// BUT NOT when the real intent is populating content or configuring SEO
if (hasCreationVerb && hasSection && !hasContentIntent && !hasSeoIntent) {
scores.create_section = scores.create_section || { score: 0, keywordHits: 0, boostHits: 0 };
scores.create_section.score += 20;
}
// ── Module creation intent ──
// "create/new" + "module/component" = strong signal for create_module
if (hasCreationVerb && hasModule) {
scores.create_module = scores.create_module || { score: 0, keywordHits: 0, boostHits: 0 };
scores.create_module.score += 20;
}
// ── Module edit intent ──
// "edit/modify/change" + "module/component" = strong signal for edit_module
if (hasEditVerb && hasModule) {
scores.edit_module = scores.edit_module || { score: 0, keywordHits: 0, boostHits: 0 };
scores.edit_module.score += 20;
}
// ── Decisive create vs edit for modules ──
// When both create_module and edit_module have scores, apply decisive differentiation
if (hasModule && scores.create_module && scores.edit_module) {
if (hasCreationVerb && !hasEditVerb) {
// Clearly creation intent → penalize edit
scores.edit_module.score = Math.max(0, scores.edit_module.score - 15);
} else if (hasEditVerb && !hasCreationVerb) {
// Clearly edit intent → penalize create
scores.create_module.score = Math.max(0, scores.create_module.score - 15);
}
}
// ── Record CRUD intent ──
// Any CRUD verb + "record/data/entry" = signal for manage_records
if (hasCrudVerb && hasRecord) {
scores.manage_records = scores.manage_records || { score: 0, keywordHits: 0, boostHits: 0 };
scores.manage_records.score += 15;
}
// ── Penalize manage_media when context is clearly about something else ──
// If the task mentions section/module/record context, media is a step not the workflow
if (scores.manage_media && (hasSection || hasModule || hasRecord)) {
// Only keep media score if there's an explicit media action verb ("upload", "replace")
if (!hasMediaAction) {
scores.manage_media.score = Math.max(0, Math.floor(scores.manage_media.score * 0.3));
}
}
// ── Boost manage_media only when it's the clear primary intent ──
// "upload/replace" + "image/photo" WITHOUT section/module/record context
if (hasMediaAction && hasImageWord && !hasSection && !hasModule && !hasRecord) {
scores.manage_media = scores.manage_media || { score: 0, keywordHits: 0, boostHits: 0 };
scores.manage_media.score += 10;
}
}
/**
* Detect the best workflow for a given task description.
* Returns the top match with confidence, or suggestions if ambiguous.
*
* @param {string} task - The user's task description
* @returns {{ workflow: string, confidence: number, alternatives: Array }}
*/
export function detectWorkflow(task) {
const normalizedTask = prepareTaskForMatching(task);
const scores = {};
// ── Phase 1: Keyword + boost scoring ──
for (const [workflowId, pattern] of Object.entries(WORKFLOW_PATTERNS)) {
let score = 0;
let keywordHits = 0;
let boostHits = 0;
// Check keyword matches
for (const keyword of pattern.keywords) {
if (normalizedTask.includes(normalizeText(keyword))) {
keywordHits++;
}
}
// Check boost matches
for (const boost of pattern.boost) {
if (normalizedTask.includes(normalizeText(boost))) {
boostHits++;
}
}
score = (keywordHits * pattern.weight) + (boostHits * 3);
scores[workflowId] = { score, keywordHits, boostHits };
}
// ── Phase 2: Contextual adjustments ──
// Uses regex word matching to catch intent patterns that substring matching misses
applyContextAdjustments(scores, normalizedTask);
// Sort by score descending
const ranked = Object.entries(scores)
.filter(([, data]) => data.score > 0)
.sort(([, a], [, b]) => b.score - a.score);
if (ranked.length === 0) {
return {
workflow: null,
confidence: 0,
alternatives: []
};
}
const [topId, topData] = ranked[0];
const maxPossibleScore = WORKFLOW_PATTERNS[topId].keywords.length * WORKFLOW_PATTERNS[topId].weight
+ WORKFLOW_PATTERNS[topId].boost.length * 3;
const confidence = Math.min(topData.score / Math.max(maxPossibleScore * 0.15, 1), 1);
// Check if top 2 are close (ambiguous)
const alternatives = ranked.slice(1, 3).map(([id, data]) => ({
workflow: id,
score: data.score,
confidence: Math.min(data.score / Math.max(
WORKFLOW_PATTERNS[id].keywords.length * WORKFLOW_PATTERNS[id].weight * 0.15, 1
), 1)
}));
const isAmbiguous = alternatives.length > 0
&& alternatives[0].score > 0
&& (topData.score - alternatives[0].score) < (topData.score * 0.2);
return {
workflow: topId,
confidence: Math.round(confidence * 100) / 100,
ambiguous: isAmbiguous,
alternatives
};
}
export { WORKFLOW_PATTERNS };

View File

@@ -0,0 +1,5 @@
import { registerOrchestrateTool } from "./orchestrate.js";
export function registerOrchestratorTools(server) {
registerOrchestrateTool(server);
}

View File

@@ -0,0 +1,165 @@
import { z } from "zod";
import { detectWorkflow } from "./detector.js";
import { getWorkflow, listWorkflows } from "./workflows/index.js";
/**
* Register the orchestrate_task tool on the MCP server.
*/
export function registerOrchestrateTool(server) {
server.tool(
"orchestrate_task",
"Provides workflow context, domain rules, and step-by-step guidance for Acai CMS tasks. " +
"Returns relevant warnings, resource pointers, and suggested tool order. " +
"Optional but recommended for multi-step tasks — helps avoid common mistakes. " +
"Available workflows: create_section, populate_content, create_module, edit_module, " +
"manage_records, manage_media, seo_setup, explore_site.",
{
task: z.string().describe(
"The user's task or request in their own words. " +
"Example: 'Crear una sección de productos con categorías e imágenes'"
),
forceWorkflow: z.string().optional().describe(
"Optional: force a specific workflow instead of auto-detecting. " +
"Use when auto-detection is wrong or you know exactly which workflow to use. " +
"Values: create_section, populate_content, create_module, edit_module, " +
"manage_records, manage_media, seo_setup, explore_site"
)
},
{ readOnlyHint: true, destructiveHint: false },
async ({ task, forceWorkflow }) => {
try {
let workflowId;
let confidence;
let detectionInfo;
if (forceWorkflow) {
// Forced workflow — skip detection
workflowId = forceWorkflow;
confidence = 1.0;
detectionInfo = { method: "forced", forceWorkflow };
} else {
// Auto-detect workflow from task description
const detection = detectWorkflow(task);
if (!detection.workflow) {
// No workflow matched — return general orientation
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
workflow: "none_detected",
message: "Could not determine a specific workflow for this task. " +
"You can proceed freely using available tools, or specify a workflow with forceWorkflow.",
availableWorkflows: listWorkflows(),
generalRules: [
"Table names WITHOUT 'cms_' prefix in all tool calls",
"Primary key is ALWAYS 'num', never 'id'",
"Upload fields are ALWAYS arrays of objects with urlPath property",
"Use ONLY Twig FILTERS (pipe syntax), not Twig functions",
"Date format: YYYY-MM-DD HH:mm:ss",
"Checkbox values: 1 or 0 (number, not boolean)"
]
}, null, 2)
}]
};
}
if (detection.ambiguous) {
// Ambiguous — return top suggestions
const topWorkflow = getWorkflow(detection.workflow);
const altWorkflows = detection.alternatives
.map(a => getWorkflow(a.workflow))
.filter(Boolean);
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
workflow: "ambiguous",
message: "Multiple workflows could match this task. " +
"Pick the most appropriate one using forceWorkflow, or proceed with the top match.",
topMatch: {
id: topWorkflow.id,
name: topWorkflow.name,
description: topWorkflow.description,
confidence: detection.confidence
},
alternatives: altWorkflows.map((w, i) => ({
id: w.id,
name: w.name,
description: w.description,
confidence: detection.alternatives[i].confidence
}))
}, null, 2)
}]
};
}
workflowId = detection.workflow;
confidence = detection.confidence;
detectionInfo = {
method: "auto",
confidence: detection.confidence,
alternatives: detection.alternatives
};
}
// Load the workflow
const workflow = getWorkflow(workflowId);
if (!workflow) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: `Unknown workflow: '${workflowId}'`,
availableWorkflows: listWorkflows()
}, null, 2)
}],
isError: true
};
}
// Build the response
const response = {
success: true,
workflow: workflow.id,
name: workflow.name,
description: workflow.description,
confidence,
detection: detectionInfo,
totalSteps: workflow.steps.length,
steps: workflow.steps,
context: workflow.context,
rules: workflow.rules,
warnings: workflow.warnings,
resources: workflow.resources
};
console.error(`[Orchestrator] Detected workflow: ${workflow.id} (confidence: ${confidence}) for task: "${task.substring(0, 80)}..."`);
return {
content: [{
type: "text",
text: JSON.stringify(response, null, 2)
}]
};
} catch (error) {
console.error("[Orchestrator] Error:", error);
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: error.message
}, null, 2)
}],
isError: true
};
}
}
);
}

View File

@@ -0,0 +1,85 @@
export const createModuleWorkflow = {
id: "create_module",
name: "Create Module",
description: "Design and create an HTML module by writing project files directly, then compile it in the CMS.",
steps: [
{
step: 1,
action: "Understand the design",
description: "Clarify with user: what does the module show? Is it a hero, grid, list, slider, CTA, form?",
tool: null,
critical: "Get clear requirements before writing code. Ask about: layout, colors, responsive behavior, editable fields."
},
{
step: 2,
action: "Review project styling and patterns",
description: "Use the saved project styles and nearby modules as reference before writing code.",
tool: "save_project_styles",
critical: "Align typography, spacing, colors, and component patterns with the existing project."
},
{
step: 3,
action: "Create the module files",
description: "Write index-base.tpl, style.css, script.js, and optional hook.php directly in the module folder.",
tool: "acai-write",
critical: "Use project-relative paths. Create complete files. Keep variable names lowercase, descriptive, and stable."
},
{
step: 4,
action: "Refine targeted blocks if needed",
description: "Use incremental replacements for small fixes instead of rewriting whole files.",
tool: "acai-line-replace",
critical: "Prefer block edits for existing files to reduce token usage and avoid accidental rewrites."
},
{
step: 5,
action: "Compile the module",
description: "Compile after editing index-base.tpl so the CMS syncs index.tpl and builder metadata.",
tool: "compile_module",
critical: "This is mandatory after every index-base.tpl change."
},
{
step: 6,
action: "Set example data",
description: "Set example/static data for module preview. MUST call get_module first to discover the variable schema.",
tool: "set_module_example_data",
critical: "Call get_module first to get ALL variable names. Then fill EVERY variable with realistic example data. Missing variables = blank preview."
},
{
step: 7,
action: "Check module rendering",
description: "Test the module with specific variable values to verify it renders correctly.",
tool: "check_module",
critical: "Test with realistic values. Check for Twig syntax errors, broken images, layout issues."
}
],
context: {
builder_vars: "data-field-type attribute on elements creates editable fields. Types: textfield (single line text), headfield (heading), textbox (multiline), wysiwyg (rich HTML), link (URL), upload (single image), uploadBackground (background image), uploadMulti (gallery), list (dropdown options), multiv2 (repeatable block).",
component_syntax: "c-if='varname' shows/hides element based on variable. c-for='item in items' loops over array. c-hidden='true' makes element invisible (for config vars). c-else after c-if for alternative content.",
module_structure: "Create index-base.tpl, style.css, script.js, and optional hook.php in the module directory. Compile to generate builder.json and the public templates.",
css_conventions: "Use TailwindCSS by default. For custom CSS: BEM naming with kebab-case. Root class should match module name. Avoid !important.",
upload_in_modules: "Upload fields are arrays. Single image: {{ varname[0].urlPath | imagec(WIDTH) }}. Background: style=\"background-image: url('{{ varname[0].urlPath | imagec(1920) }}')\". Gallery: {% for img in varname %}{{ img.urlPath }}{% endfor %}."
},
rules: [
"Variable names: lowercase, no spaces, no accents, no special characters",
"Labels must be UNIQUE — duplicate labels create shared fields",
"Upload fields are ALWAYS arrays — access with [0].urlPath",
"Use ONLY Twig FILTERS (pipe syntax), not Twig functions",
"c-if='varname' for conditional rendering of optional fields",
"c-hidden='true' for configuration variables not shown to end user",
"data-field-width on upload elements to set image optimization width",
"For multiv2 (repeatable): parent element needs data-field-type='multiv2', children are the repeated fields"
],
warnings: [
"DO NOT use duplicate labels — they create shared/linked fields",
"DO NOT forget to set example data — the module will appear blank in the editor",
"DO NOT use Twig functions (range, random, etc.) — only filters work",
"DO NOT access upload vars as strings — always use varname[0].urlPath (array)",
"DO NOT mix React/Vue syntax — use Twig for templating, vanilla JS for interactivity"
],
resources: [
"acai://resources/guia-builder-vars",
"acai://resources/guia-atributos-acai",
"acai://resources/guia-programacion-acai"
]
};

View File

@@ -0,0 +1,110 @@
export const createSectionWorkflow = {
id: "create_section",
name: "Create New Section",
description: "Full workflow for creating a new website section: table + fields + module + template + content.",
steps: [
{
step: 1,
action: "Understand requirements",
description: "Clarify with user: section name, type (multi/single/category), fields needed, whether it needs URL (enlace), SEO meta tags.",
tool: null,
critical: "Ask before acting. Multi = multiple records (blog, products). Single = one record (about page). Category = grouping for other sections."
},
{
step: 2,
action: "Check existing tables",
description: "List current tables to avoid naming conflicts and understand existing structure.",
tool: "list_tables",
critical: "Table names must be unique. Check if a similar section already exists."
},
{
step: 3,
action: "Create the table",
description: "Create the database table with correct type and configuration.",
tool: "create_table",
critical: "type must be: 'multi' (multiple records), 'single' (one record), 'category' (grouping), or 'separador' (menu separator). Set enlace=true if records need their own URL page."
},
{
step: 4,
action: "Add fields to the table",
description: "Create all necessary fields with correct types and configuration.",
tool: "edit_table_field",
critical: "Can batch multiple fields in one call. Field types: textfield, textbox, wysiwyg, date, checkbox, list, upload, multitext, codigo, separator."
},
{
step: 5,
action: "Verify table schema",
description: "Get the complete schema to confirm all fields were created correctly.",
tool: "get_table_schema",
critical: "Verify all fields exist with correct types before proceeding to module creation."
},
{
step: 6,
action: "Design and create the listing module",
description: "Create an HTML module that displays a list/grid of records from this section.",
tool: "save_module",
critical: "Use Twig syntax. Access records with the 'get' filter. Primary key is 'num' not 'id'. Upload fields are ALWAYS arrays: use record.field[0].urlPath | imagec(width)."
},
{
step: 7,
action: "Set module example data",
description: "Set example/static data for module preview. MUST call get_module first to discover ALL variables.",
tool: "set_module_example_data",
critical: "Every builder variable must have example data. Missing variables cause blank previews."
},
{
step: 8,
action: "Add sample content",
description: "Create 2-3 sample records with realistic content and images. If table has enlace=true, include the 'enlace' field with a URL slug.",
tool: "create_or_update_record",
critical: "Date format: YYYY-MM-DD HH:mm:ss. Checkbox: 1 or 0. Upload fields: use upload_record_image separately. For sections with enlace, creating records first ensures directory structure is ready."
},
{
step: 9,
action: "Create detail template (if enlace=true)",
description: "If the section has enlace enabled, create the detail page template that shows when navigating to a record's URL.",
tool: "save_general_section",
critical: "Use 'thisrecord' variable to access the current record. Same Twig rules apply. Note: save_general_section will auto-initialize the directory if needed."
},
{
step: 10,
action: "Verify the result",
description: "Check module rendering with actual variable values.",
tool: "check_module",
critical: "Test with actual variable values to ensure no rendering errors."
}
],
context: {
twig_filters: "Use 'get' filter for DB queries: {% set items = 'tablename' | get('WHERE active=1', 'ORDER BY num DESC', 10) %}. Use 'imagec' for image resize: {{ path | imagec(400) }}. Use 'module' to include other modules: {{ 'modulename' | module(vars) }}.",
field_types: "textfield (single line), textbox (multiline), wysiwyg (rich HTML), date (YYYY-MM-DD), checkbox (0/1), list (dropdown/radio/checkbox), upload (files/images), multitext (key-value pairs), codigo (code editor), separator (visual divider).",
list_field_config: "Static options: optionsType='text', optionsText='value1|Label 1\\nvalue2|Label 2'. Table relation: optionsType='table', optionsTablename='tablename', optionsValueField='num', optionsLabelField='name'. SQL: optionsType='query', optionsText='SELECT num,name FROM cms_tablename'.",
builder_vars: "data-field-type attribute on HTML elements creates editable fields. Types: textfield, headfield, textbox, wysiwyg, link, upload, uploadBackground, uploadMulti, list, multiv2. Variable names derived from labels (lowercase, no spaces/accents).",
upload_rules: "Upload fields ALWAYS return arrays. Single image: {{ record.imagen[0].urlPath | imagec(WIDTH) }}. Gallery loop: {% for img in record.galeria %}{{ img.urlPath }}{% endfor %}. Check existence: {% if record.imagen and record.imagen|length > 0 %}."
},
rules: [
"Table names WITHOUT 'cms_' prefix in all tool calls",
"Primary key is ALWAYS 'num', never 'id'",
"Upload fields are ALWAYS arrays of objects with urlPath property",
"Use ONLY Twig FILTERS (pipe syntax), not Twig functions",
"Date format: YYYY-MM-DD HH:mm:ss",
"Checkbox values: 1 or 0 (number, not boolean)",
"Enlace (URL slug): auto-formatted to /path/ with slashes",
"Variable names in modules: lowercase, no spaces, no accents, no special chars",
"c-if='varname' for conditional rendering, c-hidden='true' for invisible config vars",
"When using 'get' filter: SQL string syntax, NOT objects. Example: 'WHERE num > 5'"
],
warnings: [
"DO NOT use record.imagen.urlPath — it's record.imagen[0].urlPath (array!)",
"DO NOT use 'id' as primary key — Acai uses 'num'",
"DO NOT forget to set example data after creating a module — it will look blank",
"DO NOT create a detail template if enlace is false — there's no URL to navigate to",
"DO NOT use Twig functions like range() — only filters (pipe syntax) are available",
"For best results with new enlace sections, create records BEFORE calling save_general_section to ensure directory structure exists"
],
resources: [
"acai://resources/guia-builder-vars",
"acai://resources/guia-twig-filters",
"acai://resources/guia-atributos-acai",
"acai://resources/guia-registros"
]
};

View File

@@ -0,0 +1,64 @@
export const editModuleWorkflow = {
id: "edit_module",
name: "Edit Module",
description: "Modify an existing HTML module: update code, styles, variables, or structure.",
steps: [
{
step: 1,
action: "Get current module code",
description: "Read the current HTML, CSS, JS, and PHP of the module.",
tool: "get_module",
critical: "ALWAYS read the current code before modifying. Understand existing variables, structure, and styling."
},
{
step: 2,
action: "Check where it's used",
description: "Find all pages and records using this module to understand impact.",
tool: "check_module_usage",
critical: "Know the blast radius of your changes — how many live pages will be affected."
},
{
step: 3,
action: "Make changes",
description: "Update the module code with the required modifications.",
tool: "save_module",
critical: "Pass the module 'id' parameter to update (not create). save_module REPLACES the entire module — include ALL html/css/js, not just the changed parts."
},
{
step: 4,
action: "Update example data if needed",
description: "If you added or renamed variables, update the example data to match.",
tool: "set_module_example_data",
critical: "Call get_module first to discover new variable names. Fill ALL variables, including new ones."
},
{
step: 5,
action: "Verify rendering",
description: "Test the modified module with variable values to confirm changes work.",
tool: "check_module",
critical: "Test with realistic values. Compare rendering before and after changes."
}
],
context: {
builder_vars: "data-field-type attribute on elements creates editable fields. Types: textfield, headfield, textbox, wysiwyg, link, upload, uploadBackground, uploadMulti, list, multiv2.",
component_syntax: "c-if='varname' shows/hides element. c-for='item in items' loops. c-hidden='true' invisible config. c-else after c-if.",
save_behavior: "save_module with 'id' parameter = UPDATE. Without 'id' = CREATE new. The tool replaces the ENTIRE module code, not a diff."
},
rules: [
"ALWAYS include the full html/css/js when saving — save_module replaces everything",
"Pass the 'id' parameter to update an existing module",
"Variable names: lowercase, no spaces, no accents",
"Labels must be UNIQUE across the module",
"Upload fields are ALWAYS arrays — access with [0].urlPath"
],
warnings: [
"DO NOT remove existing variables without checking usage — they may have data on live pages",
"DO NOT rename variables — it breaks existing data bindings. Add new ones instead if needed",
"DO NOT save partial code (just HTML without CSS) — save_module replaces ALL sections",
"DO NOT forget to update example data when adding new variables"
],
resources: [
"acai://resources/guia-builder-vars",
"acai://resources/guia-atributos-acai"
]
};

View File

@@ -0,0 +1,48 @@
export const exploreSiteWorkflow = {
id: "explore_site",
name: "Explore Site",
description: "Get an overview of the current Acai site: sections, modules, content.",
steps: [
{
step: 1,
action: "List all tables/sections",
description: "Get the complete site structure with all sections, their types, and menu order.",
tool: "list_tables",
critical: "This returns the site's skeleton: all sections with type (multi/single/category/separador), menu name, and order."
},
{
step: 2,
action: "Inspect sections of interest",
description: "Get the full schema of specific sections to understand their fields and configuration.",
tool: "get_table_schema",
critical: "Look at field types, required fields, list configurations, and upload fields."
},
{
step: 3,
action: "List all modules",
description: "See all available design components/modules.",
tool: "list_modules",
critical: "Modules are the visual building blocks. Each has HTML, CSS, JS, and builder variables."
},
{
step: 4,
action: "Sample content",
description: "Preview records in key sections to understand what content exists.",
tool: "list_table_records",
critical: "Use limit=5 to get a representative sample without overwhelming the response."
}
],
context: {
orientation: "list_tables returns all sections with their type: 'multi' (multiple records like blog/products), 'single' (one record like about page), 'category' (grouping for other sections), 'separador' (menu separator). This is the site's architecture.",
modules_overview: "list_modules shows all components. Use get_module on specific ones to see their HTML/CSS/JS code and builder variables."
},
rules: [
"Table names WITHOUT 'cms_' prefix",
"Primary key is 'num', never 'id'"
],
warnings: [
"DO NOT modify anything during exploration — this workflow is read-only",
"DO NOT assume field names — always check the schema first"
],
resources: []
};

View File

@@ -0,0 +1,44 @@
import { createSectionWorkflow } from "./createSection.js";
import { populateContentWorkflow } from "./populateContent.js";
import { createModuleWorkflow } from "./createModule.js";
import { editModuleWorkflow } from "./editModule.js";
import { manageRecordsWorkflow } from "./manageRecords.js";
import { manageMediaWorkflow } from "./manageMedia.js";
import { seoSetupWorkflow } from "./seoSetup.js";
import { exploreSiteWorkflow } from "./exploreSite.js";
/**
* Registry of all available workflows.
* Keyed by workflow ID for fast lookup.
*/
export const WORKFLOWS = {
create_section: createSectionWorkflow,
populate_content: populateContentWorkflow,
create_module: createModuleWorkflow,
edit_module: editModuleWorkflow,
manage_records: manageRecordsWorkflow,
manage_media: manageMediaWorkflow,
seo_setup: seoSetupWorkflow,
explore_site: exploreSiteWorkflow,
};
/**
* Get a workflow by ID.
* @param {string} id - Workflow identifier
* @returns {object|null} The workflow definition or null
*/
export function getWorkflow(id) {
return WORKFLOWS[id] || null;
}
/**
* Get a summary list of all available workflows (for help/listing).
*/
export function listWorkflows() {
return Object.values(WORKFLOWS).map((w) => ({
id: w.id,
name: w.name,
description: w.description,
totalSteps: w.steps.length,
}));
}

View File

@@ -0,0 +1,53 @@
export const manageMediaWorkflow = {
id: "manage_media",
name: "Manage Media",
description: "Image upload, generation, replacement, and management.",
steps: [
{
step: 1,
action: "Prepare or generate images",
description: "Use an existing image URL/asset or generate an AI image for the content.",
tool: "generate_image",
critical: "generate_image uses Nano Banana AI. Existing remote image URLs can also be passed directly to upload tools."
},
{
step: 2,
action: "Upload to record",
description: "Attach images to a record's upload field.",
tool: "upload_record_image",
critical: "Requires: tableName, recordId, fieldName, imageUrl. The image is downloaded server-side and attached to the record."
},
{
step: 3,
action: "List current uploads",
description: "Check what's already uploaded in a field to know if replacing or adding.",
tool: "list_record_uploads",
critical: "Returns array of upload objects with uploadId needed for replace/delete operations."
},
{
step: 4,
action: "Replace or delete if needed",
description: "Replace an existing image or delete an upload.",
tool: "replace_record_image OR delete_record_upload",
critical: "Both require the uploadId from list_record_uploads. replace_record_image downloads new image and swaps it."
}
],
context: {
upload_structure: "Upload fields store arrays of objects: [{urlPath, fileName, fileSize, mimeType, uploadDate}]. Access in Twig templates: record.field[0].urlPath | imagec(width).",
image_sources: "Use existing remote image URLs, project assets, or Nano Banana AI image generation.",
assets_upload: "upload_image_to_assets: uploads to website /images/ folder (not tied to a record). Accepts base64, data URI, or URL. Can resize and compress.",
s3_upload: "upload_image_to_s3: uploads to Amazon S3. Returns public S3 URL. Accepts URL, local path, base64, or data URI."
},
rules: [
"Table names WITHOUT 'cms_' prefix",
"Primary key is 'num', never 'id'",
"Upload fields are ALWAYS arrays of objects with urlPath property",
"Use imagec filter for resizing: {{ path | imagec(width_in_pixels) }}"
],
warnings: [
"DO NOT try to upload before creating the record — the record must exist first",
"DO NOT confuse upload_record_image (attaches to record) with upload_image_to_assets (saves to /images/ folder)",
"DO NOT delete uploads without confirming — the image will be removed from the live page"
],
resources: []
};

View File

@@ -0,0 +1,64 @@
export const manageRecordsWorkflow = {
id: "manage_records",
name: "Manage Records",
description: "CRUD operations on existing records: query, create, update, and delete data.",
steps: [
{
step: 1,
action: "Get table schema",
description: "Understand field names, types, and constraints before querying or modifying.",
tool: "get_table_schema",
critical: "Know the exact field names and types. Upload fields require special handling."
},
{
step: 2,
action: "Query records",
description: "List or search records to find the ones to work with.",
tool: "list_table_records",
critical: "Use 'where' param for SQL WHERE filtering. Use 'limit' for pagination. Use 'page' for page navigation."
},
{
step: 3,
action: "Create or update records",
description: "Create new records or update existing ones with correct field values.",
tool: "create_or_update_record",
critical: "Pass 'recordId' for update, omit for create. Only included fields are modified on update. Field values must match field types."
},
{
step: 4,
action: "Handle uploads if needed",
description: "Upload images or files to record fields.",
tool: "upload_record_image",
critical: "Separate call per image per field per record. Cannot set upload fields via create_or_update_record."
},
{
step: 5,
action: "Verify changes",
description: "Query the records again to confirm changes were applied correctly.",
tool: "list_table_records",
critical: "Confirm all fields have the expected values, including upload fields."
}
],
context: {
querying: "list_table_records supports: where='campo = \"valor\"' (SQL WHERE), page=1 (pagination), limit=20 (records per page). WHERE clause uses SQL string syntax.",
updating: "Pass recordId + fields object to update. Only the fields included in the object are modified — other fields are left unchanged.",
creating: "Omit recordId to create. Can batch insert by passing fields as an array of objects.",
deleting: "delete_table_records requires tableName and recordIds (array of IDs). Use deleteAll=true to delete everything (DANGEROUS)."
},
rules: [
"Table names WITHOUT 'cms_' prefix in all tool calls",
"Primary key is ALWAYS 'num', never 'id'",
"Upload fields CANNOT be set via create_or_update_record — use upload_record_image",
"Date format: YYYY-MM-DD HH:mm:ss",
"Checkbox values: 1 or 0 (number, not boolean)",
"WHERE clauses use SQL string syntax: where='nombre = \"valor\"'"
],
warnings: [
"DO NOT use 'id' to reference records — use 'num'",
"DO NOT set upload fields via create_or_update_record — it will not work",
"DO NOT delete records without confirming with the user first"
],
resources: [
"acai://resources/guia-registros"
]
};

View File

@@ -0,0 +1,70 @@
export const populateContentWorkflow = {
id: "populate_content",
name: "Populate Content",
description: "Bulk record creation with images for an existing section.",
steps: [
{
step: 1,
action: "Get table schema",
description: "Understand all fields and their types before creating records.",
tool: "get_table_schema",
critical: "Know the exact field names and types. Upload fields cannot be set via create_or_update_record."
},
{
step: 2,
action: "List existing records",
description: "Check what already exists to avoid duplicates.",
tool: "list_table_records",
critical: "Review existing content before adding new records."
},
{
step: 3,
action: "Generate images if needed",
description: "Create AI images for the content being created when existing assets are not available.",
tool: "generate_image",
critical: "Generate the image first and use the returned URL for upload later."
},
{
step: 4,
action: "Create records",
description: "Create all records with text content. Can batch insert multiple records in one call.",
tool: "create_or_update_record",
critical: "Batch insert: pass an array of objects in 'fields' parameter. Date format: YYYY-MM-DD HH:mm:ss. Checkbox: 1 or 0."
},
{
step: 5,
action: "Upload images to records",
description: "Attach images to each record's upload fields.",
tool: "upload_record_image",
critical: "Must call SEPARATELY for each record+field combination. Cannot batch image uploads. Need the record's num/ID from step 4."
},
{
step: 6,
action: "Verify records",
description: "Confirm all records were created with correct data.",
tool: "list_table_records",
critical: "Check that all fields are populated correctly including upload fields."
}
],
context: {
batch_insert: "create_or_update_record supports batch: pass fields as an array of objects instead of a single object. Each object is one record. Returns an array of created record IDs.",
image_sources: "Use existing project/client assets when available, or generate_image for AI-generated images via Nano Banana.",
upload_flow: "1. Create record first (get its num/ID). 2. Then call upload_record_image with tableName, recordId, fieldName, imageUrl. 3. The image is downloaded server-side and attached to the record."
},
rules: [
"Table names WITHOUT 'cms_' prefix in all tool calls",
"Primary key is ALWAYS 'num', never 'id'",
"Upload fields CANNOT be set via create_or_update_record — use upload_record_image",
"Date format: YYYY-MM-DD HH:mm:ss",
"Checkbox values: 1 or 0 (number, not boolean)",
"Enlace field: auto-formatted to /path/ with slashes if not provided"
],
warnings: [
"DO NOT try to set upload field values in create_or_update_record — use upload_record_image after creation",
"DO NOT forget that batch insert returns an array of created record IDs — you need these for image uploads",
"DO NOT upload images before creating the record — the record must exist first"
],
resources: [
"acai://resources/guia-registros"
]
};

View File

@@ -0,0 +1,58 @@
export const seoSetupWorkflow = {
id: "seo_setup",
name: "SEO Setup",
description: "Configure SEO for a section: meta tags, URL slugs, and structured data.",
steps: [
{
step: 1,
action: "Get current table schema",
description: "Check if seo_metas is already enabled and if enlace (URL slug) exists.",
tool: "get_table_schema",
critical: "Look for seo_metas flag and enlace configuration in the schema response."
},
{
step: 2,
action: "Enable SEO meta tags",
description: "Turn on seo_metas in the table schema to add meta title/description fields.",
tool: "update_table_schema",
critical: "Set seo_metas=true in the schema. This adds SEO fields to each record."
},
{
step: 3,
action: "Enable enlace for URL slugs",
description: "Enable enlace so records get their own URL-friendly pages.",
tool: "update_table_schema",
critical: "Set enlace=true. This auto-generates /section/record-name/ URLs for each record."
},
{
step: 4,
action: "Update records with SEO data",
description: "Fill in SEO fields for each record: meta title, meta description.",
tool: "create_or_update_record",
critical: "SEO fields are typically: seo_title, seo_description. Check the schema for exact field names."
},
{
step: 5,
action: "Create or update detail template",
description: "Ensure the detail page template includes proper meta tags and structured data.",
tool: "save_general_section",
critical: "The template uses 'thisrecord' variable. Include meta tags in the template for SEO."
}
],
context: {
enlace_behavior: "When enlace is enabled, Acai auto-generates URL slugs in /section/record-name/ format. The enlace field value is auto-formatted with slashes.",
seo_fields: "Enabling seo_metas adds meta title and description fields to the record editor. These are used in the <head> of the detail page.",
detail_template: "The general section template (save_general_section) defines what renders when a user visits a record's URL. Uses 'thisrecord' to access the current record's data."
},
rules: [
"Table names WITHOUT 'cms_' prefix",
"update_table_schema requires both tableName and the schema object",
"Enlace values are auto-formatted to /path/ format",
"SEO meta fields are only available after enabling seo_metas on the table"
],
warnings: [
"DO NOT enable enlace on a 'single' type table — single tables have only one record and usually don't need individual URLs",
"DO NOT forget to create a detail template after enabling enlace — without it, record URLs show blank pages"
],
resources: []
};

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