Initial commit
This commit is contained in:
26
.env.example
Normal file
26
.env.example
Normal 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
23
Dockerfile
Normal 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
335
README.md
Normal 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
884
dashboard/css/main.css
Normal 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
33
dashboard/index.html
Normal 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">⚙</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
133
dashboard/js/api.js
Normal 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
252
dashboard/js/app.js
Normal 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);
|
||||
101
dashboard/js/components/agent-timeline.js
Normal file
101
dashboard/js/components/agent-timeline.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
180
dashboard/js/components/chat.js
Normal file
180
dashboard/js/components/chat.js
Normal 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">⚙</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">⚙</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;
|
||||
}
|
||||
}
|
||||
171
dashboard/js/components/event-log.js
Normal file
171
dashboard/js/components/event-log.js
Normal 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">▼</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';
|
||||
}
|
||||
147
dashboard/js/components/session-form.js
Normal file
147
dashboard/js/components/session-form.js
Normal 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">×</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">−</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');
|
||||
}
|
||||
144
dashboard/js/components/session-inspector.js
Normal file
144
dashboard/js/components/session-inspector.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
67
dashboard/js/components/sidebar.js
Normal file
67
dashboard/js/components/sidebar.js
Normal 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">×</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>';
|
||||
}
|
||||
}
|
||||
199
dashboard/js/components/toolbar.js
Normal file
199
dashboard/js/components/toolbar.js
Normal 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">↻ Refresh</button>
|
||||
<button class="btn btn-sm" id="btn-context-debug" title="View context debug">🔎 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">◑</button>
|
||||
<button class="btn btn-sm btn-danger" id="btn-delete-session" title="Delete session">🗑</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 + ' — ' + 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal 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
143
docs/ACAI-CLAUDE.md
Normal 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
474
docs/builder-fields.md
Normal 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
225
docs/css-js-conventions.md
Normal 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
415
docs/hooks-and-api.md
Normal 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
100
docs/mcp-tools-reference.md
Normal 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
105
docs/modular-system.md
Normal 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.
|
||||
77
docs/module-creation-guide.md
Normal file
77
docs/module-creation-guide.md
Normal 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
104
docs/pages-and-records.md
Normal 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
262
docs/production-patterns.md
Normal 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 }} €</div>
|
||||
<div class="text-2xl font-semibold ml-4">{{ thisrecord.precio_descuento }} €</div>
|
||||
</div>
|
||||
<div c-else class="text-2xl font-semibold mt-4">{{ thisrecord.precio }} €</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
85
docs/quick-reference.md
Normal 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
209
docs/twig-filters.md
Normal 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)`
|
||||
695
docs/vue-builder-examples.md
Normal file
695
docs/vue-builder-examples.md
Normal 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 (`<span>`) para guiar al usuario
|
||||
6. **Color del texto en tab Colores**: El color general del texto va en su propio tab, no mezclado con el overlay
|
||||
|
||||
### Tabs configurados
|
||||
|
||||
```javascript
|
||||
tabsConfig: [
|
||||
{ id: "configuracion", label: "Configuración", color: "#f59e0b", icon: '<svg>...</svg>' },
|
||||
{ id: "imagenes", label: "Imágenes", color: "#10b981", icon: '<svg>...</svg>' },
|
||||
{ id: "textos", label: "Textos", color: "#3b82f6", icon: '<svg>...</svg>' },
|
||||
{ id: "enlaces", label: "Enlaces", color: "#ef4444", icon: '<svg>...</svg>' },
|
||||
{ id: "colores", label: "Colores", color: "#8b5cf6", icon: '<svg>...</svg>' }
|
||||
]
|
||||
```
|
||||
|
||||
### Componentes utilizados
|
||||
- `acai-vue-tabs` - Sistema de tabs con storage-key y apply-theme-styles
|
||||
- `acai-vue-selectv2` - Selectores (algunos con `:toggle-icons`)
|
||||
- `acai-vue-textfield` - Campos de texto simple (pretítulo, subtítulo)
|
||||
- `acai-vue-title` - Encabezado principal con placeholder
|
||||
- `acai-vue-linkv2` - Enlaces con `:show_text="true"`
|
||||
- `acai-vue-upload` - Uploads de imagen/video/logo con todas las props necesarias
|
||||
- `acai-vue-colorpicker` - Pickers de color con textfield oculto asociado
|
||||
|
||||
### Iconos con toggle
|
||||
|
||||
```javascript
|
||||
iconosSombra: {
|
||||
'': '<svg>...(icon-tabler-x)</svg>',
|
||||
'1': '<svg>...(icon-tabler-check)</svg>'
|
||||
},
|
||||
iconosTipoImagen: {
|
||||
'': '<svg>...(icon-tabler-photo)</svg>',
|
||||
'1': '<svg>...(icon-tabler-video)</svg>'
|
||||
},
|
||||
iconosOverlay: {
|
||||
'': '<svg>...(icon-tabler-square)</svg>',
|
||||
'1': '<svg>...(icon-tabler-gradient)</svg>'
|
||||
}
|
||||
```
|
||||
|
||||
### Código completo
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-if="data">
|
||||
<acai-vue-tabs v-if="data" :tabs="tabsConfig" :storage-key="'banner-slideshow-tabs-' + (section_id || 'default')" :apply-theme-styles="true">
|
||||
|
||||
<!-- TAB: CONFIGURACIÓN -->
|
||||
<template #configuracion="{ color }">
|
||||
<div class="w-full mb-6">
|
||||
<p class="text-xl font-semibold text-gray-800">Ajustes generales del banner</p>
|
||||
</div>
|
||||
|
||||
<!-- Lado texto -->
|
||||
<div class="flex w-full items-center">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M9 15h-2" /><path d="M13 12h-6" /><path d="M11 9h-4" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Posición del texto :</b> Define la alineación del contenido dentro del banner.</p>
|
||||
</div>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'ladotexto'" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ver sombra -->
|
||||
<div class="w-full items-center mt-6">
|
||||
<div class="w-full flex items-center">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-shadow"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M13 12h5" /><path d="M13 15h4" /><path d="M13 18h1" /><path d="M13 9h4" /><path d="M13 6h1" /></svg>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'versombra'" :toggle-icons="iconosSombra" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Sombra en textos :</b> Aplica un efecto de sombra a los textos del banner.</p>
|
||||
</div>
|
||||
|
||||
<!-- Container -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-arrow-autofit-width"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6" /><path d="M10 18h-7" /><path d="M21 18h-7" /><path d="M6 15l-3 3l3 3" /><path d="M18 15l3 3l-3 3" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Ancho del contenedor :</b> Limita el ancho máximo del contenido textual.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto ocupa todo el ancho disponible (Full container).</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'container'" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Altura banner -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-arrow-autofit-height"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 20h-6a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h6" /><path d="M18 14v7" /><path d="M18 3v7" /><path d="M15 18l3 3l3 -3" /><path d="M15 6l3 -3l3 3" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Altura del banner :</b> Altura visible de la sección del banner.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto es pantalla completa (100vh).</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'alturadelbanner'" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- TAB: IMÁGENES -->
|
||||
<template #imagenes="{ color }">
|
||||
<div class="w-full mb-6">
|
||||
<p class="text-xl font-semibold text-gray-800">Fondo y elementos visuales del banner</p>
|
||||
</div>
|
||||
|
||||
<!-- Tipo de imagen (selector imagen/video) -->
|
||||
<div class="w-full items-center mt-6">
|
||||
<div class="w-full flex items-center">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-photo-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15h-3a3 3 0 0 1 -3 -3v-6a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v3" /><path d="M9 12a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3l0 -6" /><path d="M3 12l2.296 -2.296a2.41 2.41 0 0 1 3.408 0l.296 .296" /><path d="M14 13.5v3l2.5 -1.5l-2.5 -1.5" /><path d="M7 6v.01" /></svg>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'tipodeimagen'" :toggle-icons="iconosTipoImagen" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Tipo de fondo :</b> Selecciona si el fondo del banner será una imagen o un vídeo.</p>
|
||||
</div>
|
||||
|
||||
<!-- Imágenes (visible cuando es imagen o vacío) -->
|
||||
<div class="flex w-full items-center mt-6" v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == ''">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" :style="{ color: color }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M15 6l.01 0" /><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -8" /><path d="M3 13l4 -4a3 5 0 0 1 3 0l4 4" /><path d="M13 12l2 -2a3 5 0 0 1 3 0l3 3" /><path d="M8 21l.01 0" /><path d="M12 21l.01 0" /><path d="M16 21l.01 0" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Imágenes :</b> Añade las imágenes que rotarán en el slideshow del banner.</p>
|
||||
</div>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-upload ref="upload_imagenes" :reference="'upload_imagenes'" :tablename="'builder_custom'" :fieldname="builder.vars.imagenes.relations.builder_custom" :recordnum="data.imagenes.recordNum" :field="data.imagenes" :builder_field="builder.vars.imagenes" :presavetempid="data.imagenes.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('imagenes',data,false,'upload_imagenes')" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video (visible cuando es video) -->
|
||||
<div class="flex w-full items-center mt-6" v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == '1'">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4z" /><path d="M3 6m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Vídeo :</b> Sube el vídeo de fondo del banner.</p>
|
||||
</div>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-upload ref="upload_video" :reference="'upload_video'" :tablename="'builder_custom'" :fieldname="builder.vars.video.relations.builder_custom" :recordnum="data.video.recordNum" :field="data.video" :builder_field="builder.vars.video" :presavetempid="data.video.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('video',data,false,'upload_video')" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-icons"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6.5 6.5m-3.5 0a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0" /><path d="M2.5 21h8l-4 -7z" /><path d="M14 3l7 7" /><path d="M14 10l7 -7" /><path d="M14 14h7v7h-7z" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Logo :</b> Imagen del logotipo que aparecerá sobre el banner.</p>
|
||||
</div>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-upload ref="upload_logo" :reference="'upload_logo'" :tablename="'builder_custom'" :fieldname="builder.vars.logo.relations.builder_custom" :recordnum="data.logo.recordNum" :field="data.logo" :builder_field="builder.vars.logo" :presavetempid="data.logo.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('logo',data,false,'upload_logo')" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tipo de overlay -->
|
||||
<div class="w-full items-center mt-6">
|
||||
<div class="w-full flex items-center">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-background"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 8l4 -4" /><path d="M14 4l-10 10" /><path d="M4 20l16 -16" /><path d="M20 10l-10 10" /><path d="M20 16l-4 4" /></svg>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'tipodeoverlay'" :toggle-icons="iconosOverlay" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Tipo de overlay :</b> Elige si la capa de color se aplica de forma uniforme o con degradado.</p>
|
||||
</div>
|
||||
|
||||
<!-- Color del overlay -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-test-pipe"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M20 8.04l-12.122 12.124a2.857 2.857 0 1 1 -4.041 -4.04l12.122 -12.124" /><path d="M7 13h8" /><path d="M19 15l1.5 1.6a2 2 0 1 1 -3 0l1.5 -1.6" /><path d="M15 3l6 6" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Color del overlay :</b> Color y opacidad de la capa que se superpone sobre la imagen o vídeo.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto el overlay es transparente.</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordeloverlay'" :label="'Color overlay'" :color="'transparent'" @save-data="saveData"></acai-vue-colorpicker>
|
||||
</div>
|
||||
<div style="display: none">
|
||||
<acai-vue-textfield :builder="builder" :data="data" :field="'colordeloverlay'" @save-data="saveData"></acai-vue-textfield>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- TAB: TEXTOS -->
|
||||
<template #textos="{ color }">
|
||||
<div class="w-full mb-6">
|
||||
<p class="text-xl font-semibold text-gray-800">Contenido textual del banner</p>
|
||||
</div>
|
||||
|
||||
<!-- Pretítulo -->
|
||||
<div class="flex w-full items-center">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-text-size"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7v-2h13v2" /><path d="M10 5v14" /><path d="M12 19h-4" /><path d="M15 13v-1h6v1" /><path d="M18 12v7" /><path d="M17 19h2" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Pretítulo :</b> Texto que aparece encima del título principal.</p>
|
||||
</div>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-textfield :builder="builder" :data="data" :field="'pretitulo'" :placeholder="'Ej: Bienvenidos'" @save-data="saveData"></acai-vue-textfield>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Título -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" viewBox="0 0 24 24" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M7 12h10" /><path d="M7 4v16" /><path d="M17 4v16" /><path d="M15 20h4" /><path d="M15 4h4" /><path d="M5 20h4" /><path d="M5 4h4" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Título :</b> Encabezado principal del banner.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-600 font-light mt-2"><b class="text-black">Recuerda :</b> utiliza las etiquetas <span class="text-black font-semibold"><span> </span></span> para resaltar las palabras clave.</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-title :builder="builder" :data="data" :field="'titulo'" placeholder="Título del banner" @save-data="saveData"></acai-vue-title>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtítulo -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-text-size"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7v-2h13v2" /><path d="M10 5v14" /><path d="M12 19h-4" /><path d="M15 13v-1h6v1" /><path d="M18 12v7" /><path d="M17 19h2" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Subtítulo :</b> Texto que aparece debajo del título principal.</p>
|
||||
</div>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-textfield :builder="builder" :data="data" :field="'subtitulo'" :placeholder="'Ej: Tu solución ideal'" @save-data="saveData"></acai-vue-textfield>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- TAB: ENLACES -->
|
||||
<template #enlaces="{ color }">
|
||||
<div class="w-full mb-6">
|
||||
<p class="text-xl font-semibold text-gray-800">Botón de llamada a la acción</p>
|
||||
</div>
|
||||
|
||||
<!-- Enlace -->
|
||||
<div class="flex w-full items-center">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentcolor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M9 15l6 -6" /><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" /><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Enlace :</b> Configura el botón con su texto y destino.</p>
|
||||
</div>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-linkv2 :builder="builder" :data="data" :field="'enlace'" @save-data="saveData" :show_text="true"></acai-vue-linkv2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radio borde enlace -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-border-radius"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-4a4 4 0 0 1 4 -4h4" /><path d="M16 4l0 .01" /><path d="M20 4l0 .01" /><path d="M20 8l0 .01" /><path d="M20 12l0 .01" /><path d="M4 16l0 .01" /><path d="M20 16l0 .01" /><path d="M4 20l0 .01" /><path d="M8 20l0 .01" /><path d="M12 20l0 .01" /><path d="M16 20l0 .01" /><path d="M20 20l0 .01" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Radio del borde :</b> Redondeo de las esquinas del botón de enlace.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto es 'sm' (ligeramente redondeado).</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'radiobordeenlace'" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- TAB: COLORES -->
|
||||
<template #colores="{ color }">
|
||||
<div class="w-full mb-6">
|
||||
<p class="text-xl font-semibold text-gray-800">Personalización de colores</p>
|
||||
</div>
|
||||
|
||||
<!-- Color del texto -->
|
||||
<div class="flex w-full items-center">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M8.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M12.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M16.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Color del texto :</b> Color general de todos los textos del banner.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto es blanco (#ffffff).</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordeltexto'" :label="'Color del texto'" :color="'#ffffff'" @save-data="saveData"></acai-vue-colorpicker>
|
||||
</div>
|
||||
<div style="display: none">
|
||||
<acai-vue-textfield :builder="builder" :data="data" :field="'colordeltexto'" @save-data="saveData"></acai-vue-textfield>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</acai-vue-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
<script>
|
||||
module.exports = {
|
||||
props: ["active", "section_id"],
|
||||
data() {
|
||||
return {
|
||||
data: null,
|
||||
builder: null,
|
||||
idiomas: IDIOMAS,
|
||||
tabsConfig: [
|
||||
{ id: "configuracion", label: "Configuración", color: "#f59e0b", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>' },
|
||||
{ id: "imagenes", label: "Imágenes", color: "#10b981", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M11 20h-4a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v4" /><path d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l3 3" /><path d="M14 14l1 -1c.31 -.298 .644 -.497 .987 -.596" /><path d="M18.42 15.61a2.1 2.1 0 0 1 2.97 2.97l-3.39 3.42h-3v-3l3.42 -3.39" /></svg>' },
|
||||
{ id: "textos", label: "Textos", color: "#3b82f6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 10h-14" /><path d="M5 6h14" /><path d="M14 14h-9" /><path d="M5 18h6" /><path d="M18 15v6" /><path d="M15 18h6" /></svg>' },
|
||||
{ id: "enlaces", label: "Enlaces", color: "#ef4444", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15l6 -6"/><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"/><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"/></svg>' },
|
||||
{ id: "colores", label: "Colores", color: "#8b5cf6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" /><path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" /><path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" /><path d="M17 17l0 .01" /></svg>' },
|
||||
],
|
||||
iconosSombra: {
|
||||
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-shadow-off"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5.634 5.638a9 9 0 0 0 12.728 12.727m1.68 -2.32a9 9 0 0 0 -12.086 -12.088" /><path d="M16 12h2" /><path d="M13 15h2" /><path d="M13 18h1" /><path d="M13 9h4" /><path d="M13 6h1" /><path d="M3 3l18 18" /></svg>',
|
||||
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-shadow"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M13 12h5" /><path d="M13 15h4" /><path d="M13 18h1" /><path d="M13 9h4" /><path d="M13 6h1" /></svg>'
|
||||
},
|
||||
iconosTipoImagen: {
|
||||
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-photo"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" /><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3" /></svg>',
|
||||
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4z" /><path d="M3 6m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" /></svg>'
|
||||
},
|
||||
iconosOverlay: {
|
||||
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-square"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /></svg>',
|
||||
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-gradient"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M7 3v18" /><path d="M3 14h4" /><path d="M3 10h4" /><path d="M3 6h4" /><path d="M3 18h4" /></svg>'
|
||||
}
|
||||
};
|
||||
},
|
||||
components: {
|
||||
'acai-vue-tabs': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetabs.vue?timestamp=' + new Date().getTime()),
|
||||
"acai-vue-selectv2": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivueselect.vue?timestamp=" + new Date().getTime()),
|
||||
"acai-vue-colorpicker": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuecolorpicker.vue?timestamp=" + new Date().getTime()),
|
||||
"acai-vue-upload": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivueupload.vue?timestamp=" + new Date().getTime()),
|
||||
"acai-vue-title": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetitle.vue?timestamp=" + new Date().getTime()),
|
||||
"acai-vue-linkv2": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuelinkv2.vue?timestamp=" + new Date().getTime()),
|
||||
"acai-vue-textfield": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetextfield.vue?timestamp=" + new Date().getTime()),
|
||||
},
|
||||
mounted() { this.$emit("child-mounted"); },
|
||||
methods: { saveData() { this.$emit("save-data"); } },
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo 2: [Módulo de texto genérico]
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<acai-vue-tabs v-if="data" :tabs="tabsConfig" :storage-key="'texto-cabecera-tabs-' + (section_id || 'default')" :apply-theme-styles="true">
|
||||
|
||||
<!-- Tab Configuración -->
|
||||
<template #config="{ color }">
|
||||
<div class="w-full mb-6">
|
||||
<p class="text-xl font-semibold text-gray-800">Configuración general del módulo</p>
|
||||
</div>
|
||||
|
||||
<!-- Formato (2 opciones = toggle) -->
|
||||
<div class="w-full items-center mt-6">
|
||||
<div class="w-full flex items-center">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-layout"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z" /><path d="M4 13m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z" /><path d="M14 4m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z" /></svg>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'formato'" :toggle-icons="iconosFormato" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Formato :</b> Vertical (todo en una columna) u Horizontal (cabecera y texto en 2 columnas).</p>
|
||||
</div>
|
||||
|
||||
<!-- Alineación texto -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-align-justified"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 6l16 0" /><path d="M4 12l16 0" /><path d="M4 18l12 0" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Alineación texto :</b> Alineación del contenido.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es izquierda.</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'alineaciontexto'" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container texto -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-arrow-autofit-width"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6" /><path d="M10 18h-7" /><path d="M21 18h-7" /><path d="M6 15l-3 3l3 3" /><path d="M18 15l3 3l-3 3" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Container texto :</b> Ancho máximo del contenido.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto ocupa el ancho completo.</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'containertexto'" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estilo enlace -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-click"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12l3 0" /><path d="M12 3l0 3" /><path d="M7.8 7.8l-2.2 -2.2" /><path d="M16.2 7.8l2.2 -2.2" /><path d="M7.8 16.2l-2.2 2.2" /><path d="M12 12l9 3l-4 2l-2 4l-3 -9" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Estilo enlace :</b> Estilo visual del botón.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto usa el color principal (Main color).</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'estiloenlace'" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radio borde enlace -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-border-radius"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-4a4 4 0 0 1 4 -4h4" /><path d="M16 4l0 .01" /><path d="M20 4l0 .01" /><path d="M20 8l0 .01" /><path d="M20 12l0 .01" /><path d="M4 16l0 .01" /><path d="M20 16l0 .01" /><path d="M4 20l0 .01" /><path d="M8 20l0 .01" /><path d="M12 20l0 .01" /><path d="M16 20l0 .01" /><path d="M20 20l0 .01" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Radio borde enlace :</b> Redondeo del botón.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es "sm".</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'radiobordeenlace'" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Tab Textos -->
|
||||
<template #textos="{ color }">
|
||||
<div class="w-full mb-6">
|
||||
<p class="text-xl font-semibold text-gray-800">Contenido textual del módulo</p>
|
||||
</div>
|
||||
|
||||
<!-- Pretítulo -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-text-size"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7v-2h13v2" /><path d="M10 5v14" /><path d="M12 19h-4" /><path d="M15 13v-1h6v1" /><path d="M18 12v7" /><path d="M17 19h2" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Pretítulo :</b> Texto que aparece encima del título principal.</p>
|
||||
</div>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-textfield :builder="builder" :data="data" :field="'pretitulo'" :placeholder="'Ej: Descubre más'" @save-data="saveData"></acai-vue-textfield>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Título -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-heading"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 12h10" /><path d="M7 5v14" /><path d="M17 5v14" /><path d="M15 19h4" /><path d="M15 5h4" /><path d="M5 19h4" /><path d="M5 5h4" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Título :</b> Encabezado principal del módulo.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-600 font-light mt-2"><b class="text-black">Recuerda :</b> utiliza las etiquetas <span class="text-black font-semibold"><span> </span></span> para resaltar las palabras clave.</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-title :builder="builder" :data="data" :field="'titulo'" placeholder="Título del módulo" @save-data="saveData"></acai-vue-title>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtítulo -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-text-size"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7v-2h13v2" /><path d="M10 5v14" /><path d="M12 19h-4" /><path d="M15 13v-1h6v1" /><path d="M18 12v7" /><path d="M17 19h2" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Subtítulo :</b> Texto que aparece debajo del título principal.</p>
|
||||
</div>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-textfield :builder="builder" :data="data" :field="'subtitulo'" :placeholder="'Ej: Conoce nuestros servicios'" @save-data="saveData"></acai-vue-textfield>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Texto -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-file-text"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" /><path d="M9 9l1 0" /><path d="M9 13l6 0" /><path d="M9 17l6 0" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Texto :</b> Contenido descriptivo principal.</p>
|
||||
</div>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-wysiwyg :builder="builder" :data="data" :field="'texto'" @save-data="saveData"></acai-vue-wysiwyg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Tab Enlaces -->
|
||||
<template #enlaces="{ color }">
|
||||
<div class="w-full mb-6">
|
||||
<p class="text-xl font-semibold text-gray-800">Enlaces del módulo</p>
|
||||
</div>
|
||||
|
||||
<!-- Enlace -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-link"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15l6 -6" /><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" /><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Enlace :</b> Botón de acción principal.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Si no se configura, el botón no se mostrará.</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-linkv2 :builder="builder" :data="data" :field="'enlace'" :show_text="true" @save-data="saveData"></acai-vue-linkv2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Tab Colores -->
|
||||
<template #colores="{ color }">
|
||||
<div class="w-full mb-6">
|
||||
<p class="text-xl font-semibold text-gray-800">Personalización de colores</p>
|
||||
</div>
|
||||
|
||||
<!-- Color de fondo -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-paint"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z" /><path d="M19 6h1a2 2 0 0 1 2 2a5 5 0 0 1 -5 5l-5 0v2" /><path d="M10 15m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Color de fondo :</b> Color de fondo de toda la sección.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es transparente.</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordefondo'" :label="'Color de fondo'" :color="'transparent'" @save-data="saveData"></acai-vue-colorpicker>
|
||||
</div>
|
||||
<div style="display: none">
|
||||
<acai-vue-textfield :builder="builder" :data="data" :field="'colordefondo'" @save-data="saveData"></acai-vue-textfield>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color del pretítulo -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-palette"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M7.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M15.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Color del pretítulo :</b> Color del texto del pretítulo.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es #111827.</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordelpretitulo'" :label="'Color pretítulo'" :color="'#111827'" @save-data="saveData"></acai-vue-colorpicker>
|
||||
</div>
|
||||
<div style="display: none">
|
||||
<acai-vue-textfield :builder="builder" :data="data" :field="'colordelpretitulo'" @save-data="saveData"></acai-vue-textfield>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color del título -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-palette"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M7.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M15.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Color del título :</b> Color del texto del título principal.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es #111827.</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordeltitulo'" :label="'Color título'" :color="'#111827'" @save-data="saveData"></acai-vue-colorpicker>
|
||||
</div>
|
||||
<div style="display: none">
|
||||
<acai-vue-textfield :builder="builder" :data="data" :field="'colordeltitulo'" @save-data="saveData"></acai-vue-textfield>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color título resaltado -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-color-swatch"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" /><path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" /><path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" /><path d="M17 17l0 .01" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Color título resaltado :</b> Color de las palabras resaltadas con <span>.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto usa el color principal (Main color).</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-selectv2 :builder="builder" :data="data" :field="'colortituloresaltado'" @save-data="saveData"></acai-vue-selectv2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color del subtítulo -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-palette"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M7.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M15.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Color del subtítulo :</b> Color del texto del subtítulo.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es #111827.</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordelsubtitulo'" :label="'Color subtítulo'" :color="'#111827'" @save-data="saveData"></acai-vue-colorpicker>
|
||||
</div>
|
||||
<div style="display: none">
|
||||
<acai-vue-textfield :builder="builder" :data="data" :field="'colordelsubtitulo'" @save-data="saveData"></acai-vue-textfield>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color texto -->
|
||||
<div class="flex w-full items-center mt-6">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-typography"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 20l3 0" /><path d="M14 20l7 0" /><path d="M6.9 15l6.9 0" /><path d="M10.2 6.3l5.8 13.7" /><path d="M5 20l6 -16l2 0l7 16" /></svg>
|
||||
<p class="leading-snug text-gray-600"><b class="text-black">Color texto :</b> Color del contenido descriptivo.</p>
|
||||
</div>
|
||||
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es #374151.</p>
|
||||
<div class="relative mt-2 ml-14">
|
||||
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colortexto'" :label="'Color texto'" :color="'#374151'" @save-data="saveData"></acai-vue-colorpicker>
|
||||
</div>
|
||||
<div style="display: none">
|
||||
<acai-vue-textfield :builder="builder" :data="data" :field="'colortexto'" @save-data="saveData"></acai-vue-textfield>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
</acai-vue-tabs>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
module.exports = {
|
||||
props: ["active", "section_id"],
|
||||
data() {
|
||||
return {
|
||||
data: null,
|
||||
builder: null,
|
||||
idiomas: IDIOMAS,
|
||||
tabsConfig: [
|
||||
{ id: "config", label: "Configuración", color: "#f59e0b", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>' },
|
||||
{ id: "textos", label: "Textos", color: "#3b82f6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 10h-14" /><path d="M5 6h14" /><path d="M14 14h-9" /><path d="M5 18h6" /><path d="M18 15v6" /><path d="M15 18h6" /></svg>' },
|
||||
{ id: "enlaces", label: "Enlaces", color: "#ef4444", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15l6 -6" /><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" /><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" /></svg>' },
|
||||
{ id: "colores", label: "Colores", color: "#8b5cf6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" /><path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" /><path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" /><path d="M17 17l0 .01" /></svg>' }
|
||||
],
|
||||
iconosFormato: {
|
||||
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-layout-rows"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" /><path d="M4 12l16 0" /></svg>',
|
||||
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-layout-columns"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" /><path d="M12 4l0 16" /></svg>'
|
||||
}
|
||||
};
|
||||
},
|
||||
components: {
|
||||
'acai-vue-tabs': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetabs.vue?timestamp=' + new Date().getTime()),
|
||||
'acai-vue-selectv2': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivueselect.vue?timestamp=' + new Date().getTime()),
|
||||
'acai-vue-colorpicker': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuecolorpicker.vue?timestamp=' + new Date().getTime()),
|
||||
'acai-vue-linkv2': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuelinkv2.vue?timestamp=' + new Date().getTime()),
|
||||
'acai-vue-title': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetitle.vue?timestamp=' + new Date().getTime()),
|
||||
'acai-vue-wysiwyg': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuewysiwyg.vue?timestamp=' + new Date().getTime()),
|
||||
'acai-vue-textfield': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetextfield.vue?timestamp=' + new Date().getTime()),
|
||||
},
|
||||
mounted() { this.$emit("child-mounted"); },
|
||||
methods: { saveData() { this.$emit("save-data"); } },
|
||||
};
|
||||
</script>
|
||||
```
|
||||
484
docs/vue-builder-rules.md
Normal file
484
docs/vue-builder-rules.md
Normal 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
265
execute-requirements.md
Normal 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
13
mcp-server/.dockerignore
Normal 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
17
mcp-server/.env.example
Normal 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
28
mcp-server/Dockerfile
Normal 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
473
mcp-server/README.md
Normal 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
|
||||
195
mcp-server/acai-file-tools.json
Normal file
195
mcp-server/acai-file-tools.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
142
mcp-server/auth/apiClient.js
Normal file
142
mcp-server/auth/apiClient.js
Normal 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 });
|
||||
};
|
||||
};
|
||||
251
mcp-server/auth/credentials.js
Normal file
251
mcp-server/auth/credentials.js
Normal 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
26
mcp-server/auth/index.js
Normal 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';
|
||||
|
||||
14
mcp-server/auth/localClient.js
Normal file
14
mcp-server/auth/localClient.js
Normal 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: [...] }
|
||||
}
|
||||
207
mcp-server/auth/redisClient.js
Normal file
207
mcp-server/auth/redisClient.js
Normal 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
44
mcp-server/cluster.js
Normal 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
102
mcp-server/config/index.js
Normal 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
263
mcp-server/debug_client.py
Normal 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()
|
||||
40
mcp-server/docker-compose.prod.yml
Normal file
40
mcp-server/docker-compose.prod.yml
Normal 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
|
||||
36
mcp-server/docker-compose.yml
Normal file
36
mcp-server/docker-compose.yml
Normal 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
85
mcp-server/fieldData.json
Normal 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
|
||||
}
|
||||
76
mcp-server/http-mcp-client.js
Normal file
76
mcp-server/http-mcp-client.js
Normal 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
788
mcp-server/httpServer.js
Normal 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 };
|
||||
234
mcp-server/httpServer.sse.backup
Normal file
234
mcp-server/httpServer.sse.backup
Normal 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
48
mcp-server/index.js
Normal 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
44
mcp-server/index.new.js
Normal 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
917
mcp-server/monitor.html
Normal 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()">← 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 = { "&": "&", "<": "<", ">": ">" };
|
||||
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
224
mcp-server/monitor.js
Normal 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
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
28
mcp-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
81
mcp-server/prompts/guiaCamposTablas.js
Normal file
81
mcp-server/prompts/guiaCamposTablas.js
Normal 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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
13
mcp-server/prompts/index.js
Normal file
13
mcp-server/prompts/index.js
Normal 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);
|
||||
}
|
||||
161
mcp-server/requestMonitor.js
Normal file
161
mcp-server/requestMonitor.js
Normal 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);
|
||||
}
|
||||
}
|
||||
8
mcp-server/resources/index.js
Normal file
8
mcp-server/resources/index.js
Normal 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
100
mcp-server/server.js
Normal 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
92
mcp-server/stdio.js
Normal 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})`);
|
||||
108
mcp-server/tools/auth/index.js
Normal file
108
mcp-server/tools/auth/index.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
46
mcp-server/tools/files/delete.js
Normal file
46
mcp-server/tools/files/delete.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
51
mcp-server/tools/files/glob.js
Normal file
51
mcp-server/tools/files/glob.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
65
mcp-server/tools/files/grep.js
Normal file
65
mcp-server/tools/files/grep.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
60
mcp-server/tools/files/helpers.js
Normal file
60
mcp-server/tools/files/helpers.js
Normal 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,
|
||||
};
|
||||
}
|
||||
15
mcp-server/tools/files/index.js
Normal file
15
mcp-server/tools/files/index.js
Normal 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);
|
||||
}
|
||||
67
mcp-server/tools/files/lineReplace.js
Normal file
67
mcp-server/tools/files/lineReplace.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
60
mcp-server/tools/files/view.js
Normal file
60
mcp-server/tools/files/view.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
59
mcp-server/tools/files/write.js
Normal file
59
mcp-server/tools/files/write.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
357
mcp-server/tools/helpers/ACAI_ENDPOINTS.md
Normal file
357
mcp-server/tools/helpers/ACAI_ENDPOINTS.md
Normal 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.
|
||||
270
mcp-server/tools/helpers/ERROR_HANDLING.md
Normal file
270
mcp-server/tools/helpers/ERROR_HANDLING.md
Normal 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.
|
||||
587
mcp-server/tools/helpers/acaiHttpClient.js
Normal file
587
mcp-server/tools/helpers/acaiHttpClient.js
Normal 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;
|
||||
6
mcp-server/tools/helpers/authSchema.js
Normal file
6
mcp-server/tools/helpers/authSchema.js
Normal 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;
|
||||
215
mcp-server/tools/helpers/errorHandler.js
Normal file
215
mcp-server/tools/helpers/errorHandler.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
102
mcp-server/tools/helpers/fileBuilder.js
Normal file
102
mcp-server/tools/helpers/fileBuilder.js
Normal 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
24
mcp-server/tools/index.js
Normal 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);
|
||||
}
|
||||
213
mcp-server/tools/media/generateImage.js
Normal file
213
mcp-server/tools/media/generateImage.js
Normal 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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
9
mcp-server/tools/media/index.js
Normal file
9
mcp-server/tools/media/index.js
Normal 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);
|
||||
}
|
||||
294
mcp-server/tools/media/upload.js
Normal file
294
mcp-server/tools/media/upload.js
Normal 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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
211
mcp-server/tools/media/uploadImageToAssets.js
Normal file
211
mcp-server/tools/media/uploadImageToAssets.js
Normal 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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
78
mcp-server/tools/modules/check.js
Normal file
78
mcp-server/tools/modules/check.js
Normal 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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
62
mcp-server/tools/modules/checkUsage.js
Normal file
62
mcp-server/tools/modules/checkUsage.js
Normal 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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
82
mcp-server/tools/modules/compile.js
Normal file
82
mcp-server/tools/modules/compile.js
Normal 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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
75
mcp-server/tools/modules/create.js
Normal file
75
mcp-server/tools/modules/create.js
Normal 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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
9
mcp-server/tools/modules/index.js
Normal file
9
mcp-server/tools/modules/index.js
Normal 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);
|
||||
}
|
||||
93
mcp-server/tools/modules/setExampleData.js
Normal file
93
mcp-server/tools/modules/setExampleData.js
Normal 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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
5
mcp-server/tools/navigation/index.js
Normal file
5
mcp-server/tools/navigation/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { registerNavigateBrowserTool } from './navigate.js';
|
||||
|
||||
export function registerNavigationTools(server) {
|
||||
registerNavigateBrowserTool(server);
|
||||
}
|
||||
57
mcp-server/tools/navigation/navigate.js
Normal file
57
mcp-server/tools/navigation/navigate.js
Normal 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 });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
345
mcp-server/tools/orchestrator/detector.js
Normal file
345
mcp-server/tools/orchestrator/detector.js
Normal 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 };
|
||||
5
mcp-server/tools/orchestrator/index.js
Normal file
5
mcp-server/tools/orchestrator/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { registerOrchestrateTool } from "./orchestrate.js";
|
||||
|
||||
export function registerOrchestratorTools(server) {
|
||||
registerOrchestrateTool(server);
|
||||
}
|
||||
165
mcp-server/tools/orchestrator/orchestrate.js
Normal file
165
mcp-server/tools/orchestrator/orchestrate.js
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
85
mcp-server/tools/orchestrator/workflows/createModule.js
Normal file
85
mcp-server/tools/orchestrator/workflows/createModule.js
Normal 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"
|
||||
]
|
||||
};
|
||||
110
mcp-server/tools/orchestrator/workflows/createSection.js
Normal file
110
mcp-server/tools/orchestrator/workflows/createSection.js
Normal 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"
|
||||
]
|
||||
};
|
||||
64
mcp-server/tools/orchestrator/workflows/editModule.js
Normal file
64
mcp-server/tools/orchestrator/workflows/editModule.js
Normal 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"
|
||||
]
|
||||
};
|
||||
48
mcp-server/tools/orchestrator/workflows/exploreSite.js
Normal file
48
mcp-server/tools/orchestrator/workflows/exploreSite.js
Normal 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: []
|
||||
};
|
||||
44
mcp-server/tools/orchestrator/workflows/index.js
Normal file
44
mcp-server/tools/orchestrator/workflows/index.js
Normal 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,
|
||||
}));
|
||||
}
|
||||
53
mcp-server/tools/orchestrator/workflows/manageMedia.js
Normal file
53
mcp-server/tools/orchestrator/workflows/manageMedia.js
Normal 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: []
|
||||
};
|
||||
64
mcp-server/tools/orchestrator/workflows/manageRecords.js
Normal file
64
mcp-server/tools/orchestrator/workflows/manageRecords.js
Normal 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"
|
||||
]
|
||||
};
|
||||
70
mcp-server/tools/orchestrator/workflows/populateContent.js
Normal file
70
mcp-server/tools/orchestrator/workflows/populateContent.js
Normal 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"
|
||||
]
|
||||
};
|
||||
58
mcp-server/tools/orchestrator/workflows/seoSetup.js
Normal file
58
mcp-server/tools/orchestrator/workflows/seoSetup.js
Normal 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
Reference in New Issue
Block a user