Compare commits
11 Commits
6978764540
...
f17be543ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f17be543ee | ||
|
|
967d5bf25d | ||
|
|
1c3d67847a | ||
|
|
301cef4d69 | ||
|
|
ded0e997ed | ||
|
|
2d5cc4e10a | ||
|
|
0bfc8e2b97 | ||
|
|
bcfaeb7e39 | ||
|
|
151596a52d | ||
|
|
56c8a9c066 | ||
|
|
df7dfbc280 |
@@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
El atributo `data-field-label` se convierte a variable removiendo espacios y caracteres especiales (minúsculas).
|
El atributo `data-field-label` se convierte a variable removiendo espacios y caracteres especiales (minúsculas).
|
||||||
|
|
||||||
|
Reglas obligatorias:
|
||||||
|
- Todo elemento editable con `data-field-type` debe incluir también `data-field-label`
|
||||||
|
- Si falta `data-field-label`, el builder puede generar variables temporales o incorrectas y el módulo queda mal configurado
|
||||||
|
- Usa labels descriptivos y estables; no dejes labels vacíos ni genéricos como "Campo" o "Texto"
|
||||||
|
|
||||||
|
Evita también en `index-base.tpl` las clases Tailwind con valores arbitrarios como `text-[44px]`, `font-['Cinzel']` o `leading-[1.1]`. En este stack pueden romper el parseo/compilación del template. Muévelas a `style.css`.
|
||||||
|
|
||||||
| Label | Variable |
|
| Label | Variable |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
| Categoría Noticia | `categoranoticia` |
|
| Categoría Noticia | `categoranoticia` |
|
||||||
|
|||||||
@@ -72,12 +72,29 @@ Patrón para colores configurables por el usuario:
|
|||||||
|
|
||||||
JavaScript scopeado al módulo usando `section_id`:
|
JavaScript scopeado al módulo usando `section_id`:
|
||||||
|
|
||||||
|
This is the default and expected place for module JavaScript.
|
||||||
|
Do NOT embed `<script>` tags directly inside `index-base.tpl` for normal module behavior.
|
||||||
|
Keep `index-base.tpl` for HTML/Twig markup and put interactive logic in `script.js`.
|
||||||
|
`script.js` is a static file: do NOT use Twig syntax inside it.
|
||||||
|
Do NOT write `{{ section_id }}`, `{{ variable }}`, `{% if ... %}`, or builder attributes inside `script.js`.
|
||||||
|
|
||||||
|
If JavaScript needs dynamic values, pass them from `index-base.tpl` through `data-*` attributes:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section
|
||||||
|
class="buscador-apartados"
|
||||||
|
data-section-id="{{ section_id }}"
|
||||||
|
data-hook-endpoint="/hooks/buscadorapartados_hjd8s/">
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const section = document.getElementById('{{ section_id }}');
|
document.querySelectorAll('.buscador-apartados').forEach((section) => {
|
||||||
if (section) {
|
const sectionId = section.dataset.sectionId;
|
||||||
|
const hookEndpoint = section.dataset.hookEndpoint;
|
||||||
const buttons = section.querySelectorAll('.btn');
|
const buttons = section.querySelectorAll('.btn');
|
||||||
// ...
|
// ...
|
||||||
}
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### CmsApi (Client-Side)
|
### CmsApi (Client-Side)
|
||||||
@@ -88,6 +105,25 @@ CmsApi.hook('/hooks/module_id/', { action: 'getData', id: 123 }, function(respon
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you are calling a hook that belongs to the current module, the endpoint must use the real module id:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Module folder: template/estandar/modulos/buscadorapartados_hjd8s/
|
||||||
|
CmsApi.hook('/hooks/buscadorapartados_hjd8s/', { termino: 'vela' }, function(response) {
|
||||||
|
console.log(response);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not try to build this endpoint with Twig inside `script.js`. Put the final endpoint in a `data-hook-endpoint` attribute in `index-base.tpl` if needed.
|
||||||
|
|
||||||
|
### Module Styles (`style.css`)
|
||||||
|
|
||||||
|
`style.css` is also a static file.
|
||||||
|
Do NOT use Twig syntax or builder attributes inside it.
|
||||||
|
Do NOT write selectors or values that depend on `{{ section_id }}`.
|
||||||
|
|
||||||
|
Scope styles with the module root class instead of dynamic Twig ids.
|
||||||
|
|
||||||
### Cuándo usar Vue 3
|
### Cuándo usar Vue 3
|
||||||
|
|
||||||
Usar Vue 3 CDN cuando la lógica requiera:
|
Usar Vue 3 CDN cuando la lógica requiera:
|
||||||
|
|||||||
@@ -2,7 +2,39 @@
|
|||||||
|
|
||||||
## Hooks
|
## 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`.
|
Hooks son archivos PHP que ejecutan lógica server-side. Pueden existir en dos sitios:
|
||||||
|
- Hooks globales en `hooks/hooks.<hook-id>.php`
|
||||||
|
- Hooks propios de módulo en `template/estandar/modulos/<module-id>/hook.php`
|
||||||
|
|
||||||
|
### Tipos de hooks
|
||||||
|
|
||||||
|
**1. Hook global**
|
||||||
|
- Archivo: `hooks/hooks.<hook-id>.php`
|
||||||
|
- Endpoint: `/hooks/<hook-id>/`
|
||||||
|
- Úsalo cuando la lógica se reutiliza entre módulos, páginas o formularios
|
||||||
|
|
||||||
|
**2. Hook propio de módulo**
|
||||||
|
- Archivo: `template/estandar/modulos/<module-id>/hook.php`
|
||||||
|
- Endpoint: `/hooks/<module-id>/`
|
||||||
|
- Úsalo cuando la lógica pertenece solo a ese módulo
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
- `hooks/hooks.buscar_barcos.php` -> `/hooks/buscar_barcos/`
|
||||||
|
- `template/estandar/modulos/hero_banner/hook.php` -> `/hooks/hero_banner/`
|
||||||
|
|
||||||
|
Regla práctica:
|
||||||
|
- Si el hook solo sirve a un módulo, créalo dentro del módulo
|
||||||
|
- Si varias piezas del proyecto lo van a consumir, créalo como hook global
|
||||||
|
|
||||||
|
## Reglas obligatorias para hooks
|
||||||
|
|
||||||
|
- Un hook debe devolver datos con `return [...]`
|
||||||
|
- No uses `echo json_encode(...)`
|
||||||
|
- No uses `exit`
|
||||||
|
- Para leer parámetros, usa `$_REQUEST[...]` o las variables ya inyectadas por el sistema
|
||||||
|
- En hooks, usa `CmsApi::get()` o `CocoDB::get()` como primera opción
|
||||||
|
- No uses `CocoDB::getInstance()` salvo necesidad real muy excepcional
|
||||||
|
- No escribas SQL manual con `prepare()/bind_param()` salvo que no exista forma razonable de resolverlo con `CmsApi` o `CocoDB`
|
||||||
|
|
||||||
### Estructura de un Hook
|
### Estructura de un Hook
|
||||||
|
|
||||||
@@ -34,6 +66,10 @@ No usar X-Hooks-Token en desarrollo local.
|
|||||||
|
|
||||||
### Cómo Llamar Hooks
|
### Cómo Llamar Hooks
|
||||||
|
|
||||||
|
La referencia siempre se hace con el endpoint `/hooks/<id>/`:
|
||||||
|
- Para hooks globales, `<id>` es el nombre lógico del hook sin `hooks.` ni `.php`
|
||||||
|
- Para hooks de módulo, `<id>` es el `module-id` de la carpeta del módulo
|
||||||
|
|
||||||
**Desde HTML (recomendado para módulos):**
|
**Desde HTML (recomendado para módulos):**
|
||||||
```html
|
```html
|
||||||
<hook result="myVar" endpoint="/hooks/module_id/" :param1="value1" :param2="'string'"></hook>
|
<hook result="myVar" endpoint="/hooks/module_id/" :param1="value1" :param2="'string'"></hook>
|
||||||
@@ -53,6 +89,14 @@ CmsApi.hook('/hooks/mimodulo/', {param1: 100, param2: 'texto'}, (data) => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Ejemplo real para hook de módulo:**
|
||||||
|
```js
|
||||||
|
// Módulo: template/estandar/modulos/buscadorapartados_hjd8s/
|
||||||
|
CmsApi.hook('/hooks/buscadorapartados_hjd8s/', { termino: 'vela' }, (data) => {
|
||||||
|
console.log(data);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
**Desde otro Hook PHP:**
|
**Desde otro Hook PHP:**
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
@@ -71,37 +115,49 @@ API server-side para operaciones de base de datos. Disponible en todos los hooks
|
|||||||
|
|
||||||
### Read — `CmsApi::get()`
|
### Read — `CmsApi::get()`
|
||||||
|
|
||||||
|
## IMPORTANTE : Las tablas y nombres de campos puedes extraerlas de los esquemas en cms/data/schemas/<nombre_de_tabla>.ini.php
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// Todos los registros
|
// Todos los registros
|
||||||
$products = CmsApi::get('productos');
|
$products = CmsApi::get("productos");
|
||||||
|
|
||||||
// Con condición WHERE
|
// Con condición WHERE en string
|
||||||
$active = CmsApi::get('productos', ['active' => 1]);
|
$active = CmsApi::get("productos", "active=1");
|
||||||
|
|
||||||
// Con orden y límite
|
// Con orden y límite
|
||||||
$latest = CmsApi::get('noticias', [], 'fecha DESC', 5);
|
$latest = CmsApi::get("noticias", "", "fecha DESC", 5);
|
||||||
|
|
||||||
// Con condición string
|
// Con condición string
|
||||||
$activos = CmsApi::get('productos', 'activo=1');
|
$activos = CmsApi::get("productos", "activo=1");
|
||||||
|
|
||||||
// Condición compleja como array
|
// Condición compleja
|
||||||
$caros = CmsApi::get('productos', [
|
$caros = CmsApi::get("productos", "precio > 100");
|
||||||
["column" => "precio", "operator" => ">", "value" => 100]
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Múltiples condiciones (AND)
|
// Múltiples condiciones (AND)
|
||||||
$resultados = CmsApi::get('productos', [
|
$resultados = CmsApi::get("productos", "activo = 1 AND stock > 0");
|
||||||
["column" => "activo", "operator" => "=", "value" => 1],
|
|
||||||
["column" => "stock", "operator" => ">", "value" => 0]
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Con operadores
|
// Con operadores
|
||||||
$expensive = CmsApi::get('productos', ['precio' => ['>=' => 100]]);
|
$expensive = CmsApi::get("productos", "precio >= 100");
|
||||||
$search = CmsApi::get('productos', ['nombre' => ['LIKE' => '%keyword%']]);
|
$search = CmsApi::get("productos", "nombre LIKE '%keyword%'");
|
||||||
$inList = CmsApi::get('productos', ['categoria_num' => ['IN' => [1, 2, 3]]]);
|
$inList = CmsApi::get("productos", "categoria_num IN (1, 2, 3)");
|
||||||
|
|
||||||
|
#### 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) |
|
||||||
|
|
||||||
// Con opciones
|
// Con opciones
|
||||||
$datos = CmsApi::get('productos', '', '', '', [
|
$datos = CmsApi::get("productos", "", "", "", [
|
||||||
'translates' => true,
|
'translates' => true,
|
||||||
'uploads' => true,
|
'uploads' => true,
|
||||||
'relations' => true,
|
'relations' => true,
|
||||||
@@ -111,6 +167,8 @@ $datos = CmsApi::get('productos', '', '', '', [
|
|||||||
|
|
||||||
### Insert — `CmsApi::insert()`
|
### Insert — `CmsApi::insert()`
|
||||||
|
|
||||||
|
## IMPORTANTE : Las tablas y nombres de campos puedes extraerlas de los esquemas en cms/data/schemas/<nombre_de_tabla>.ini.php
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// Un registro
|
// Un registro
|
||||||
CmsApi::insert('contacto', [
|
CmsApi::insert('contacto', [
|
||||||
@@ -131,8 +189,19 @@ CmsApi::insert('productos',
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Opciones de insert
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `forceNum` | Permite setear el campo `num` manualmente |
|
||||||
|
| `ignoreSchema` | Saltar validación de schema |
|
||||||
|
| `ignoreFields` | Array de campos a ignorar |
|
||||||
|
|
||||||
|
|
||||||
### Update — `CmsApi::update()`
|
### Update — `CmsApi::update()`
|
||||||
|
|
||||||
|
## IMPORTANTE : Las tablas y nombres de campos puedes extraerlas de los esquemas en cms/data/schemas/<nombre_de_tabla>.ini.php
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// Con condición string
|
// Con condición string
|
||||||
CmsApi::update('productos', ["precio" => 150], "num=1");
|
CmsApi::update('productos', ["precio" => 150], "num=1");
|
||||||
@@ -147,8 +216,19 @@ CmsApi::update('productos',
|
|||||||
CmsApi::update('productos', ["activo" => 0], "precio < 50");
|
CmsApi::update('productos', ["activo" => 0], "precio < 50");
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Opciones de update
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `forceNum` | Permite setear el campo `num` manualmente |
|
||||||
|
| `ignoreSchema` | Saltar validación de schema |
|
||||||
|
| `ignoreFields` | Array de campos a ignorar |
|
||||||
|
|
||||||
|
|
||||||
### Delete — `CmsApi::delete()`
|
### Delete — `CmsApi::delete()`
|
||||||
|
|
||||||
|
## IMPORTANTE : Las tablas y nombres de campos puedes extraerlas de los esquemas en cms/data/schemas/<nombre_de_tabla>.ini.php
|
||||||
|
|
||||||
```php
|
```php
|
||||||
CmsApi::delete('productos', "num=5");
|
CmsApi::delete('productos', "num=5");
|
||||||
|
|
||||||
@@ -187,134 +267,24 @@ CmsApi.get('tableName', { where: conditions }, function(records) {
|
|||||||
|
|
||||||
Capa de abstracción de BD de bajo nivel usada internamente por CmsApi. Usar directamente desde hooks cuando necesites más control.
|
Capa de abstracción de BD de bajo nivel usada internamente por CmsApi. Usar directamente desde hooks cuando necesites más control.
|
||||||
|
|
||||||
|
Para búsquedas y lecturas habituales, prioriza:
|
||||||
|
1. `CmsApi::get()`
|
||||||
|
2. `CocoDB::get()`
|
||||||
|
3. SQL manual solo si de verdad no hay alternativa razonable
|
||||||
|
|
||||||
### `CocoDB::get($table, $where, $order, $limit, $options)`
|
### `CocoDB::get($table, $where, $order, $limit, $options)`
|
||||||
|
## Funcionalidad exactamente igual a CmsApi::get ( ver referencia CmsApi::get )
|
||||||
|
|
||||||
```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)`
|
### `CocoDB::insertRecords($table, $records, $functions, $options)`
|
||||||
|
## Funcionalidad exactamente igual a CmsApi::insert ( ver referencia CmsApi::insert )
|
||||||
```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)`
|
### `CocoDB::updateRecords($table, $records, $where, $functions, $options)`
|
||||||
|
## Funcionalidad exactamente igual a CmsApi::update ( ver referencia CmsApi::update )
|
||||||
|
|
||||||
```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)`
|
### `CocoDB::deleteRecords($table, $where, $options)`
|
||||||
|
## Funcionalidad exactamente igual a CmsApi::delete ( ver referencia CmsApi::delete )
|
||||||
```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
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -379,12 +349,15 @@ return [
|
|||||||
<p>Total: ${{ precio.total }}</p>
|
<p>Total: ${{ precio.total }}</p>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
En este ejemplo, el endpoint usa `calcular_precio` porque el archivo vive en:
|
||||||
|
`template/estandar/modulos/calcular_precio/hook.php`
|
||||||
|
|
||||||
### Hook con Operaciones de BD
|
### Hook con Operaciones de BD
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
// hook.php del módulo "procesar_compra"
|
// hook.php del módulo "procesar_compra"
|
||||||
$producto = CmsApi::get('productos', "num=$producto_id");
|
$producto = CmsApi::get("productos", "num=" . $producto_id);
|
||||||
|
|
||||||
if (empty($producto)) {
|
if (empty($producto)) {
|
||||||
return ["success" => false, "message" => "Producto no encontrado"];
|
return ["success" => false, "message" => "Producto no encontrado"];
|
||||||
@@ -402,7 +375,7 @@ CmsApi::insert('ventas', [[
|
|||||||
]], [], ['return_last_id' => true]);
|
]], [], ['return_last_id' => true]);
|
||||||
|
|
||||||
// Actualizar stock
|
// Actualizar stock
|
||||||
$stock = CmsApi::get('stocks', "producto_num=$producto_id");
|
$stock = CmsApi::get("stocks", "producto_num=" . $producto_id);
|
||||||
if (!empty($stock)) {
|
if (!empty($stock)) {
|
||||||
CmsApi::update('stocks',
|
CmsApi::update('stocks',
|
||||||
["cantidad" => $stock[0]['cantidad'] - $cantidad],
|
["cantidad" => $stock[0]['cantidad'] - $cantidad],
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ PHP files that execute server-side logic. Triggered by:
|
|||||||
- JavaScript: `CmsApi.hook('/hooks/module_id/', {param: value}, callback)`
|
- JavaScript: `CmsApi.hook('/hooks/module_id/', {param: value}, callback)`
|
||||||
- Form action: via `c-form` attribute
|
- Form action: via `c-form` attribute
|
||||||
|
|
||||||
|
There are two valid hook locations:
|
||||||
|
- Global hooks in `hooks/hooks.<hook-id>.php` for reusable/shared server-side logic
|
||||||
|
- Module-specific hooks in `template/estandar/modulos/<module-id>/hook.php` for logic owned by a single module
|
||||||
|
|
||||||
|
How to reference them:
|
||||||
|
- Global hook `hooks/hooks.calcular_precio.php` -> endpoint `/hooks/calcular_precio/`
|
||||||
|
- Module hook `template/estandar/modulos/hero_banner/hook.php` -> endpoint `/hooks/hero_banner/`
|
||||||
|
- Module hook `template/estandar/modulos/buscadorapartados_hjd8s/hook.php` -> endpoint `/hooks/buscadorapartados_hjd8s/`
|
||||||
|
|
||||||
|
Rule of thumb:
|
||||||
|
- If the logic is only used by one module, prefer that module's `hook.php`
|
||||||
|
- If the logic will be reused by several modules/pages, create a global hook in `hooks/`
|
||||||
|
- Return arrays from hooks; do not use `echo json_encode(...)` or `exit`
|
||||||
|
|
||||||
See [docs/hooks-and-api.md](docs/hooks-and-api.md) for usage.
|
See [docs/hooks-and-api.md](docs/hooks-and-api.md) for usage.
|
||||||
|
|
||||||
## Database Access
|
## Database Access
|
||||||
@@ -103,17 +117,18 @@ Do NOT modify web-base files — they are shared across all projects.
|
|||||||
## Critical Rules
|
## 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.
|
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.
|
2. **NEVER use `mkdir` to create directories.** Instead, use `acai-write` 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. Only edit `index-base.tpl` in modules — `index.tpl`, `index-twig.tpl`, and `builder.json` are auto-generated
|
||||||
3. **Edit `index-base.tpl` using `acai_write` or `acai_line_replace`** — the server compiles automatically when the file is saved. No need to call `compile_module` manually.
|
4. Editing or creating any `index-base.tpl` through `acai-write` or `acai-line-replace` triggers automatic compilation. `compile_module` is only for manual recovery when you need to force a recompile without changing the file.
|
||||||
4. Use Twig **filters** (with `|`), never Twig functions
|
5. `script.js` and `style.css` are static files — do NOT use Twig syntax inside them. Pass dynamic values from `index-base.tpl` via `data-*` attributes.
|
||||||
5. Table names without `cms_` prefix everywhere
|
6. Use Twig **filters** (with `|`), never Twig functions
|
||||||
6. Primary key is `num`, never `id`
|
7. Table names without `cms_` prefix everywhere
|
||||||
7. Upload fields are arrays — access with `[0].urlPath`
|
8. Primary key is `num`, never `id`
|
||||||
8. Tailwind CSS as primary styling, custom CSS scoped with BEM when needed
|
9. Upload fields are arrays — access with `[0].urlPath`
|
||||||
9. Twig concatenation uses `~` operator: `'value=' ~ variable`
|
10. Tailwind CSS as primary styling, custom CSS scoped with BEM when needed
|
||||||
10. `enlace` (link) fields already include slashes — **NEVER modify an existing enlace** unless explicitly asked
|
11. Twig concatenation uses `~` operator: `'value=' ~ variable`
|
||||||
11. **NEVER modify the `controlador` field** of existing records — it defines whether a page is Builder or Standard
|
12. `enlace` (link) fields already include slashes — **NEVER modify an existing enlace** unless explicitly asked
|
||||||
|
13. **NEVER modify the `controlador` field** of existing records — it defines whether a page is Builder or Standard
|
||||||
|
|
||||||
## MCP Tools
|
## MCP Tools
|
||||||
|
|
||||||
@@ -122,8 +137,8 @@ This project has MCP tools for managing modules, records, media, and more. **Bef
|
|||||||
See [docs/mcp-tools-reference.md](docs/mcp-tools-reference.md) for the complete list of available tools and step-by-step workflows.
|
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:
|
Key workflows:
|
||||||
- **Create module**: Read [docs/module-creation-guide.md](docs/module-creation-guide.md) first → Write `index-base.tpl` via `acai_write` → `add_module_to_record` (returns sectionId) → `set_module_config_vars` (returns uploadFields) → images via uploadFields
|
- **Create module**: Read [docs/module-creation-guide.md](docs/module-creation-guide.md) first → create files with `acai-write` / refine with `acai-line-replace` → automatic compile on `index-base.tpl` → `add_module_to_record` (returns sectionId) → `set_module_config_vars` (returns uploadFields) → images via uploadFields. Use `compile_module` only if you need a manual recompile without editing the file.
|
||||||
- **Edit module**: read vars → edit `index-base.tpl` with `acai_write` or `acai_line_replace` (server compiles automatically)
|
- **Edit module**: `acai-view` → `acai-line-replace` (or `acai-write` for full rewrites) → automatic compile on `index-base.tpl`
|
||||||
- **Add images**: use `uploadFields` from `set_module_config_vars` response → `upload_record_image`
|
- **Add images**: use `uploadFields` from `set_module_config_vars` response → `upload_record_image`
|
||||||
- **Generate images**: `generate_image` → `upload_record_image` with returned URL
|
- **Generate images**: `generate_image` → `upload_record_image` with returned URL
|
||||||
|
|
||||||
@@ -4,11 +4,15 @@
|
|||||||
|
|
||||||
| Tool | Categoría | Acción |
|
| Tool | Categoría | Acción |
|
||||||
|------|-----------|--------|
|
|------|-----------|--------|
|
||||||
| `create_module` | Módulos | (Legacy) Alternativa para crear módulo — preferir acai_write |
|
| `compile_module` | Módulos | Recompilación manual de rescate cuando necesitas forzar la compilación sin editar el archivo |
|
||||||
| `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` | Módulos | Preview de cómo renderiza un módulo |
|
||||||
| `check_module_usage` | Módulos | Qué páginas usan 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 |
|
| `acai-view` | Archivos | Lee un archivo del proyecto por tramos |
|
||||||
|
| `acai-glob` | Archivos | Busca rutas de archivos por patrón |
|
||||||
|
| `acai-grep` | Archivos | Busca texto en archivos del proyecto |
|
||||||
|
| `acai-line-replace` | Archivos | Reemplaza un bloque concreto en un archivo existente |
|
||||||
|
| `acai-write` | Archivos | Crea o reescribe un archivo completo. Antes de usarlo, lee la doc correspondiente según el tipo de archivo (`module-creation-guide`, `builder-fields`, `css-js-conventions`, `hooks-and-api`) |
|
||||||
|
| `acai-delete` | Archivos | Borra un archivo del proyecto |
|
||||||
| `list_page_modules` | Registros | Lista módulos de una página |
|
| `list_page_modules` | Registros | Lista módulos de una página |
|
||||||
| `add_module_to_record` | Registros | Añade módulo a 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 |
|
| `remove_module_from_record` | Registros | Elimina módulo de una página |
|
||||||
@@ -30,29 +34,43 @@
|
|||||||
| `refresh_acai_token` | Auth | Renovar token JWT expirado |
|
| `refresh_acai_token` | Auth | Renovar token JWT expirado |
|
||||||
| `navigate_browser` | Navegación | Navegar el browser del frontend a una URL |
|
| `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 |
|
| `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 |
|
| `rollback_git` | Git | Recuperar cambios de git remoto |
|
||||||
|
|
||||||
## Flujos de trabajo
|
## Flujos de trabajo
|
||||||
|
|
||||||
### Crear un módulo nuevo desde cero
|
### Crear un módulo nuevo desde cero
|
||||||
|
|
||||||
1. `acai_write` — Escribe `index-base.tpl` en `template/estandar/modulos/NOMBRE/`. El server crea la carpeta si no existe, compila y genera todos los archivos derivados (index-twig.tpl, index.tpl, builder.json, screenshots)
|
1. `acai-write` — Crea `index-base.tpl`, `style.css`, `script.js` y cualquier hook necesario con rutas relativas al proyecto
|
||||||
2. `add_module_to_record` — Añade el módulo a una página (tabla padre, ej: `apartados`)
|
2. `acai-write` o `acai-line-replace` compilan automáticamente al tocar `index-base.tpl`
|
||||||
3. `set_module_config_vars` — Rellena las variables con contenido (textos, colores, opciones). **OBLIGATORIO** — sin esto el módulo no muestra nada. Devuelve:
|
3. `add_module_to_record` — Añade el módulo a una página (tabla padre, ej: `apartados`)
|
||||||
|
4. `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
|
- `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
|
- `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 }]`
|
- 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. Para imágenes: `generate_image` o `upload_record_image` usando el `recordNum` y `fieldName` del `uploadFields` devuelto en el paso 4
|
||||||
5. Verificar con `check_module` o recargando la página
|
6. Verificar con `check_module` o recargando la página
|
||||||
|
|
||||||
> **Nota:** `create_module` es una alternativa legacy que hace lo mismo pero con menos control sobre el contenido del template.
|
|
||||||
|
|
||||||
### Editar un módulo existente
|
### Editar un módulo existente
|
||||||
|
|
||||||
1. `get_module_config_vars` — Leer el estado actual del módulo (variables, recordNums)
|
1. `get_module_config_vars` — Leer el estado actual del módulo (variables, recordNums)
|
||||||
2. Editar `index-base.tpl` con `acai_write` o `acai_line_replace` — el server compila automáticamente al guardar
|
2. `acai-view` — Leer solo el tramo de `index-base.tpl` que se va a modificar
|
||||||
3. Si cambias variables: `set_module_config_vars` para actualizar valores
|
3. `acai-line-replace` — Editar el bloque concreto. Usa `acai-write` solo si el archivo es nuevo o el cambio es masivo
|
||||||
|
4. La compilación es automática tras cada edición de `index-base.tpl`
|
||||||
|
5. Si cambias variables: `set_module_config_vars` para actualizar valores
|
||||||
|
|
||||||
|
### Editar archivos del proyecto con bajo consumo de tokens
|
||||||
|
|
||||||
|
1. `acai-view` — Leer el archivo o un rango de líneas
|
||||||
|
2. `acai-glob` — Encontrar archivos relevantes por ruta cuando no conoces el path exacto
|
||||||
|
3. `acai-grep` — Buscar texto o atributos concretos dentro de archivos del proyecto
|
||||||
|
4. `acai-line-replace` — Reemplazar el bloque exacto en archivos existentes
|
||||||
|
5. `acai-write` — Crear archivos nuevos o reescribirlos por completo si es necesario
|
||||||
|
6. `acai-delete` — Borrar archivos solo cuando sea explícitamente necesario
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- Usa siempre rutas relativas al proyecto
|
||||||
|
- No edites `index.tpl`, `index-twig.tpl` ni `builder.json` — son auto-generados
|
||||||
|
- Tras editar cualquier `index-base.tpl` con las file tools, la compilación se ejecuta automáticamente
|
||||||
|
|
||||||
### Añadir/modificar imágenes de un módulo
|
### Añadir/modificar imágenes de un módulo
|
||||||
|
|
||||||
@@ -68,13 +86,13 @@
|
|||||||
- `tableName`: `"builder_custom"` (siempre sin cms_)
|
- `tableName`: `"builder_custom"` (siempre sin cms_)
|
||||||
- `recordId`: el recordNum del paso 1
|
- `recordId`: el recordNum del paso 1
|
||||||
- `fieldName`: el campo de relations del builder.json (ej: `image1`)
|
- `fieldName`: el campo de relations del builder.json (ej: `image1`)
|
||||||
- `imageUrl`: URL accesible desde Docker (ej: `http://localhost/cms/uploads/...`)
|
- `imageUrl`: usa la URL recomendada por la tool que generó/subió la imagen. En Forge, si `generate_image` devuelve `uploadUrl` o `fullUrl`, priorízala frente a `dockerUrl`
|
||||||
|
|
||||||
### Generar imagen con IA
|
### Generar imagen con IA
|
||||||
|
|
||||||
1. `generate_image` con prompt descriptivo + style (photographic, digital-art, minimalist...)
|
1. `generate_image` con prompt descriptivo + style (photographic, digital-art, minimalist...)
|
||||||
2. La imagen se guarda en `cms/uploads/generated/` y devuelve `dockerUrl`
|
2. La imagen se guarda en `cms/uploads/generated/` y devuelve una URL local de preview (`dockerUrl`) y, cuando aplica, una URL recomendada para subida (`uploadUrl` / `fullUrl`)
|
||||||
3. Usar esa `dockerUrl` con `upload_record_image` para asignarla a un módulo
|
3. Para `upload_record_image`, usa la URL recomendada por la tool. En Forge, prioriza `uploadUrl` o `fullUrl` si están presentes
|
||||||
|
|
||||||
### Gestionar registros de una tabla
|
### Gestionar registros de una tabla
|
||||||
|
|
||||||
@@ -85,10 +103,9 @@
|
|||||||
|
|
||||||
### Explorar el sitio
|
### Explorar el sitio
|
||||||
|
|
||||||
1. `orchestrate_task` con workflow `explore_site` — Guía para entender la estructura
|
1. `list_page_modules` — Ver qué módulos tiene cada página
|
||||||
2. `list_page_modules` — Ver qué módulos tiene cada página
|
2. `get_module_config_vars` — Ver los datos de cada módulo
|
||||||
3. `get_module_config_vars` — Ver los datos de cada módulo
|
3. `check_module` — Preview de cómo renderiza
|
||||||
4. `check_module` — Preview de cómo renderiza
|
|
||||||
|
|
||||||
## Reglas importantes para todas las tools
|
## Reglas importantes para todas las tools
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,35 @@ Each module lives in `template/estandar/modulos/<moduleId>/` with:
|
|||||||
|
|
||||||
## Creating a Module — Full Workflow
|
## Creating a Module — Full Workflow
|
||||||
|
|
||||||
|
If the module needs JavaScript, you MUST read `docs/css-js-conventions.md` before writing `index-base.tpl` or `script.js`.
|
||||||
|
If the module needs server-side logic, dynamic data processing, form handling, or reusable backend behavior, you MUST read `docs/hooks-and-api.md` before creating `hook.php` or any global hook.
|
||||||
|
If the module will call hooks from Twig, also review `docs/twig-filters.md` for the `hook` filter syntax.
|
||||||
|
If `index-base.tpl` contains builder fields (`data-field-type`), you MUST review `docs/builder-fields.md` before writing the template.
|
||||||
|
|
||||||
|
Hard rules for module files:
|
||||||
|
- `index-base.tpl` is for HTML/Twig only
|
||||||
|
- `script.js` is for module JavaScript
|
||||||
|
- `style.css` is for module CSS
|
||||||
|
- `hook.php` is for server-side logic
|
||||||
|
- Do NOT embed `<script>` tags in `index-base.tpl`
|
||||||
|
- Do NOT put PHP logic in `index-base.tpl`
|
||||||
|
- `script.js` and `style.css` are static files: do NOT use Twig syntax inside them
|
||||||
|
- Do NOT use `{{ ... }}`, `{% ... %}`, `c-if`, `c-for`, or builder attributes inside `script.js` or `style.css`
|
||||||
|
- If JavaScript needs dynamic values such as `section_id` or a hook endpoint, expose them from `index-base.tpl` via `data-*` attributes
|
||||||
|
- Every editable builder field with `data-field-type` MUST also define `data-field-label`
|
||||||
|
- Avoid Tailwind arbitrary-value syntax such as `text-[...]`, `font-[...]`, `leading-[...]`, `bg-[...]` inside `index-base.tpl`; move those styles to `style.css`
|
||||||
|
|
||||||
1. **Read style reference** (steps above)
|
1. **Read style reference** (steps above)
|
||||||
2. **`acai_write`** — Write `index-base.tpl` to `template/estandar/modulos/MODULE_ID/index-base.tpl`. The server automatically creates the directory, compiles and generates all derived files. `create_module` is a legacy alternative.
|
2. **`acai-write`** — Create the module files directly (`index-base.tpl`, `style.css`, `script.js`, optional `hook.php`) using project-relative paths and complete file contents.
|
||||||
3. **`add_module_to_record`** — Adds the module to a page. Response includes `sectionId` — use it directly in the next step.
|
- If the server-side logic belongs only to that module, create `template/estandar/modulos/<module-id>/hook.php`
|
||||||
4. **`set_module_config_vars`** — Fill variables with content. Response includes `uploadFields` with `{ fieldName, recordNum }` for each upload variable.
|
- If the logic should be reused across modules/pages, create a global hook in `hooks/hooks.<hook-id>.php`
|
||||||
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.
|
- Inside the module, reference its own hook with `/hooks/<module-id>/`
|
||||||
6. **`navigate_browser`** — Navigate to the page so the user can see the result.
|
- Example: module folder `template/estandar/modulos/buscadorapartados_hjd8s/` -> hook endpoint `/hooks/buscadorapartados_hjd8s/`
|
||||||
|
3. **Automatic compile** — Writing `index-base.tpl` automatically creates the generated template placeholders and triggers compilation. `compile_module` is only a manual recovery tool if you need to force a recompile without changing the file.
|
||||||
|
4. **`add_module_to_record`** — Adds the module to a page. Response includes `sectionId` — use it directly in the next step.
|
||||||
|
5. **`set_module_config_vars`** — Fill variables with content. Response includes `uploadFields` with `{ fieldName, recordNum }` for each upload variable.
|
||||||
|
6. **Upload images** — Use `generate_image` then `upload_record_image` with the `recordNum` and `fieldName` from step 5's `uploadFields`. No need to read builder.json or call get_module_config_vars.
|
||||||
|
7. **`navigate_browser`** — Navigate to the page so the user can see the result.
|
||||||
|
|
||||||
## HTML Field Types
|
## HTML Field Types
|
||||||
|
|
||||||
@@ -72,6 +95,6 @@ Modules with `MJMLModule: true` in their schema are email modules:
|
|||||||
- Use `section_id` variable for unique anchors/scoping
|
- Use `section_id` variable for unique anchors/scoping
|
||||||
- Use `interno` variable to detect CMS editor vs public view
|
- Use `interno` variable to detect CMS editor vs public view
|
||||||
- Include other modules with: `<module_id :param1="value1"></module_id>`
|
- Include other modules with: `<module_id :param1="value1"></module_id>`
|
||||||
- After editing `index-base.tpl` with `acai_write` or `acai_line_replace`, the server compiles automatically — no need to call `compile_module`
|
- Editing `index-base.tpl` with `acai-write` or `acai-line-replace` compiles automatically
|
||||||
- Twig uses filters (with `|`), never functions
|
- Twig uses filters (with `|`), never functions
|
||||||
- Twig concatenation uses `~`: `'value=' ~ variable`
|
- Twig concatenation uses `~`: `'value=' ~ variable`
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ The controlador defines whether the page is Builder or Standard. Changing it bre
|
|||||||
1. List current modules: `list_page_modules`
|
1. List current modules: `list_page_modules`
|
||||||
2. Get module vars: `get_module_config_vars`
|
2. Get module vars: `get_module_config_vars`
|
||||||
3. Update vars: `set_module_config_vars`
|
3. Update vars: `set_module_config_vars`
|
||||||
4. Or edit the module template: edit `index-base.tpl` → `compile_module`
|
4. Or edit the module template: edit `index-base.tpl` with `acai-line-replace` or `acai-write` → compilation runs automatically
|
||||||
|
|
||||||
## Working with Standard Pages
|
## Working with Standard Pages
|
||||||
|
|
||||||
|
|||||||
@@ -68,13 +68,15 @@ def set_dependencies(
|
|||||||
context_engine: Any,
|
context_engine: Any,
|
||||||
memory_store: Any,
|
memory_store: Any,
|
||||||
sse_emitter: Any,
|
sse_emitter: Any,
|
||||||
mcp_registry: Any,
|
claude_emitter: Any = None,
|
||||||
|
mcp_registry: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
_deps["storage"] = storage
|
_deps["storage"] = storage
|
||||||
_deps["model_adapter"] = model_adapter
|
_deps["model_adapter"] = model_adapter
|
||||||
_deps["context_engine"] = context_engine
|
_deps["context_engine"] = context_engine
|
||||||
_deps["memory_store"] = memory_store
|
_deps["memory_store"] = memory_store
|
||||||
_deps["sse"] = sse_emitter
|
_deps["sse"] = sse_emitter
|
||||||
|
_deps["claude_sse"] = claude_emitter
|
||||||
_deps["mcp_registry"] = mcp_registry
|
_deps["mcp_registry"] = mcp_registry
|
||||||
|
|
||||||
|
|
||||||
@@ -207,22 +209,33 @@ async def _execute_and_persist(orchestrator, storage, session, message) -> dict[
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/sessions/{session_id}/stream")
|
@router.get("/sessions/{session_id}/stream")
|
||||||
async def stream_session(session_id: str) -> StreamingResponse:
|
async def stream_session(session_id: str, format: str = "native") -> StreamingResponse:
|
||||||
storage = _get_storage()
|
storage = _get_storage()
|
||||||
session = await storage.get_session(session_id)
|
session = await storage.get_session(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
sse = _get_sse()
|
headers = {
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
sse.subscribe(session_id),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
headers={
|
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
},
|
}
|
||||||
|
|
||||||
|
if format == "claude":
|
||||||
|
claude_sse = _deps.get("claude_sse")
|
||||||
|
if not claude_sse:
|
||||||
|
raise HTTPException(status_code=501, detail="Claude format emitter not available")
|
||||||
|
return StreamingResponse(
|
||||||
|
claude_sse.subscribe(session_id),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
sse = _get_sse()
|
||||||
|
return StreamingResponse(
|
||||||
|
sse.subscribe(session_id),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -299,8 +299,8 @@ class ContextEngine:
|
|||||||
for d in sorted(all_docs, key=lambda d: len(d.content))
|
for d in sorted(all_docs, key=lambda d: len(d.content))
|
||||||
]
|
]
|
||||||
|
|
||||||
# Fill token budget with top-ranked docs
|
# Include ALL docs — 42K tokens fits well within model context (128K)
|
||||||
max_kb_tokens = 15_000
|
max_kb_tokens = 50_000
|
||||||
token_budget = max_kb_tokens
|
token_budget = max_kb_tokens
|
||||||
full_docs: list[MemoryDocument] = []
|
full_docs: list[MemoryDocument] = []
|
||||||
|
|
||||||
@@ -541,9 +541,29 @@ class ContextEngine:
|
|||||||
else:
|
else:
|
||||||
user_content = "Awaiting task assignment."
|
user_content = "Awaiting task assignment."
|
||||||
|
|
||||||
messages: list[dict[str, Any]] = [{"role": "user", "content": user_content}]
|
messages: list[dict[str, Any]] = []
|
||||||
|
|
||||||
# Append real conversation (assistant messages + tool results)
|
# Include previous task exchanges as conversation history
|
||||||
|
# (so the model remembers what was said in earlier turns)
|
||||||
|
for entry in session.task_history[-10:]:
|
||||||
|
summary = entry.get("summary", "")
|
||||||
|
objective = entry.get("objective", "")
|
||||||
|
if summary.startswith("User: "):
|
||||||
|
# Direct response format: "User: X → Agent: Y"
|
||||||
|
parts = summary.split(" → Agent: ", 1)
|
||||||
|
user_msg = objective or parts[0].replace("User: ", "", 1)
|
||||||
|
agent_msg = parts[1] if len(parts) > 1 else summary
|
||||||
|
messages.append({"role": "user", "content": user_msg})
|
||||||
|
messages.append({"role": "assistant", "content": agent_msg})
|
||||||
|
elif objective:
|
||||||
|
# Task with tools — include as compact exchange
|
||||||
|
messages.append({"role": "user", "content": objective})
|
||||||
|
messages.append({"role": "assistant", "content": summary[:500] if summary else "Tarea completada."})
|
||||||
|
|
||||||
|
# Current user message
|
||||||
|
messages.append({"role": "user", "content": user_content})
|
||||||
|
|
||||||
|
# Append real conversation (assistant messages + tool results from current step)
|
||||||
if conversation:
|
if conversation:
|
||||||
messages.extend(conversation)
|
messages.extend(conversation)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from .mcp.registry import MCPRegistry
|
|||||||
from .memory.store import MemoryStore
|
from .memory.store import MemoryStore
|
||||||
from .orchestrator.engine import OrchestratorEngine
|
from .orchestrator.engine import OrchestratorEngine
|
||||||
from .storage.redis import RedisStorage
|
from .storage.redis import RedisStorage
|
||||||
|
from .streaming.claude_format import ClaudeFormatEmitter, DualEmitter
|
||||||
from .streaming.sse import SSEEmitter
|
from .streaming.sse import SSEEmitter
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -38,6 +39,8 @@ logger = logging.getLogger(__name__)
|
|||||||
# Global instances (initialized in lifespan)
|
# Global instances (initialized in lifespan)
|
||||||
redis_storage = RedisStorage()
|
redis_storage = RedisStorage()
|
||||||
sse_emitter = SSEEmitter(redis_storage=redis_storage)
|
sse_emitter = SSEEmitter(redis_storage=redis_storage)
|
||||||
|
claude_emitter = ClaudeFormatEmitter()
|
||||||
|
dual_emitter = DualEmitter(sse_emitter, claude_emitter)
|
||||||
mcp_registry = MCPRegistry()
|
mcp_registry = MCPRegistry()
|
||||||
|
|
||||||
|
|
||||||
@@ -48,7 +51,6 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# 1. Connect Redis
|
# 1. Connect Redis
|
||||||
await redis_storage.connect()
|
await redis_storage.connect()
|
||||||
sse_emitter.set_storage(redis_storage)
|
|
||||||
|
|
||||||
# 2. Initialize model adapter
|
# 2. Initialize model adapter
|
||||||
if settings.default_model_provider == "openai":
|
if settings.default_model_provider == "openai":
|
||||||
@@ -82,12 +84,14 @@ async def lifespan(app: FastAPI):
|
|||||||
mcp_registry.load_config()
|
mcp_registry.load_config()
|
||||||
|
|
||||||
# 6. Wire dependencies (orchestrator is created per-message with session's MCP)
|
# 6. Wire dependencies (orchestrator is created per-message with session's MCP)
|
||||||
|
dual_emitter.set_storage(redis_storage)
|
||||||
set_dependencies(
|
set_dependencies(
|
||||||
storage=redis_storage,
|
storage=redis_storage,
|
||||||
model_adapter=model_adapter,
|
model_adapter=model_adapter,
|
||||||
context_engine=context_engine,
|
context_engine=context_engine,
|
||||||
memory_store=memory_store,
|
memory_store=memory_store,
|
||||||
sse_emitter=sse_emitter,
|
sse_emitter=dual_emitter,
|
||||||
|
claude_emitter=claude_emitter,
|
||||||
mcp_registry=mcp_registry,
|
mcp_registry=mcp_registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ class BaseAgent:
|
|||||||
):
|
):
|
||||||
if chunk.delta:
|
if chunk.delta:
|
||||||
full_text += chunk.delta
|
full_text += chunk.delta
|
||||||
|
# Only emit deltas for user-facing agents (coder, collector)
|
||||||
|
# Planner/reviewer output is internal
|
||||||
|
if self.profile.role not in ("planner", "reviewer"):
|
||||||
await self.sse.emit(
|
await self.sse.emit(
|
||||||
EventType.AGENT_DELTA,
|
EventType.AGENT_DELTA,
|
||||||
{
|
{
|
||||||
@@ -115,7 +118,7 @@ class BaseAgent:
|
|||||||
}
|
}
|
||||||
await self.sse.emit(
|
await self.sse.emit(
|
||||||
EventType.TOOL_STARTED,
|
EventType.TOOL_STARTED,
|
||||||
{"tool": chunk.tool_name, "step": step},
|
{"tool": chunk.tool_name, "tool_call_id": chunk.tool_call_id, "step": step},
|
||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,6 +126,17 @@ class BaseAgent:
|
|||||||
tool = active_tools.get(chunk.tool_call_id)
|
tool = active_tools.get(chunk.tool_call_id)
|
||||||
if tool:
|
if tool:
|
||||||
tool["arguments"] += chunk.tool_arguments
|
tool["arguments"] += chunk.tool_arguments
|
||||||
|
await self.sse.emit(
|
||||||
|
EventType.AGENT_DELTA,
|
||||||
|
{
|
||||||
|
"agent": self.profile.role,
|
||||||
|
"delta": "",
|
||||||
|
"tool_arguments": chunk.tool_arguments,
|
||||||
|
"tool_call_id": chunk.tool_call_id,
|
||||||
|
"step": step,
|
||||||
|
},
|
||||||
|
session_id=session.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
if chunk.finish_reason == "tool_use" and chunk.tool_call_id:
|
if chunk.finish_reason == "tool_use" and chunk.tool_call_id:
|
||||||
tool = active_tools.pop(chunk.tool_call_id, None)
|
tool = active_tools.pop(chunk.tool_call_id, None)
|
||||||
@@ -200,6 +214,7 @@ class BaseAgent:
|
|||||||
tool_name=tc["name"],
|
tool_name=tc["name"],
|
||||||
arguments=tc.get("parsed_arguments", {}),
|
arguments=tc.get("parsed_arguments", {}),
|
||||||
artifacts=artifacts,
|
artifacts=artifacts,
|
||||||
|
tool_call_id=tc["id"],
|
||||||
)
|
)
|
||||||
tool_fingerprints[fp] = tool_exec
|
tool_fingerprints[fp] = tool_exec
|
||||||
tool_executions.append(tool_exec)
|
tool_executions.append(tool_exec)
|
||||||
@@ -253,6 +268,7 @@ class BaseAgent:
|
|||||||
tool_name: str,
|
tool_name: str,
|
||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
artifacts: list[ArtifactSummary],
|
artifacts: list[ArtifactSummary],
|
||||||
|
tool_call_id: str = "",
|
||||||
) -> ToolExecution:
|
) -> ToolExecution:
|
||||||
"""Execute a tool and summarise the result."""
|
"""Execute a tool and summarise the result."""
|
||||||
exec_id = uuid.uuid4().hex[:12]
|
exec_id = uuid.uuid4().hex[:12]
|
||||||
@@ -299,6 +315,8 @@ class BaseAgent:
|
|||||||
"tool": tool_name,
|
"tool": tool_name,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"summary": artifact.summary[:200],
|
"summary": artifact.summary[:200],
|
||||||
|
"raw_output": raw_output[:4000],
|
||||||
|
"tool_call_id": tool_call_id,
|
||||||
},
|
},
|
||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
)
|
)
|
||||||
@@ -311,7 +329,7 @@ class BaseAgent:
|
|||||||
|
|
||||||
await self.sse.emit(
|
await self.sse.emit(
|
||||||
EventType.TOOL_COMPLETED,
|
EventType.TOOL_COMPLETED,
|
||||||
{"tool": tool_name, "status": "failed", "error": str(e)},
|
{"tool": tool_name, "status": "failed", "error": str(e), "tool_call_id": tool_call_id},
|
||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,28 +5,167 @@ from __future__ import annotations
|
|||||||
from ...models.agent import AgentProfile, AgentRole
|
from ...models.agent import AgentProfile, AgentRole
|
||||||
from .base import BaseAgent
|
from .base import BaseAgent
|
||||||
|
|
||||||
CODER_SYSTEM_PROMPT = """Eres un Agente Programador de Acai CMS. Tu rol es ejecutar tareas de implementación usando las herramientas MCP disponibles.
|
CODER_SYSTEM_PROMPT = """Eres el asistente de desarrollo de Acai CMS. Ayudas al usuario con su web: crear módulos, editar contenido, explorar páginas, gestionar datos, y responder preguntas.
|
||||||
|
|
||||||
## Instrucciones
|
# Acai CMS — Project Instructions
|
||||||
- Concéntrate en la descripción del paso actual.
|
|
||||||
- Usa herramientas para lograr la tarea.
|
|
||||||
- Sé preciso y minucioso.
|
|
||||||
- Reporta lo que lograste, problemas encontrados y hechos relevantes.
|
|
||||||
- NO produzcas explicaciones innecesarias — produce resultados.
|
|
||||||
- Responde SIEMPRE en español.
|
|
||||||
|
|
||||||
## Uso de herramientas
|
This is an Acai CMS website project. Follow these instructions when working with the codebase.
|
||||||
- CONSULTA la Knowledge Base ANTES de actuar — tiene la referencia completa de tools y flujos de trabajo.
|
|
||||||
- Para CREAR/EDITAR MÓDULOS usa `acai_write` sobre `template/estandar/modulos/NOMBRE/index-base.tpl`. El server crea la carpeta si no existe, compila y genera todos los archivos derivados automáticamente. NO necesitas compile_module.
|
## Environment
|
||||||
- `create_module` es legacy — funciona pero `acai_write` es el flujo estándar.
|
|
||||||
- Para GESTIONAR REGISTROS de tablas (apartados, travesias, etc.) usa `create_or_update_record`.
|
- The site runs in Docker, typically at **http://localhost:8080**
|
||||||
- Flujo de módulo nuevo: acai_write index-base.tpl → add_module_to_record → set_module_config_vars.
|
- You can make HTTP requests to test pages, APIs, or form submissions
|
||||||
- tableName siempre SIN prefijo cms_ (ej: apartados, NO cms_apartados).
|
- If you need to inspect the live site, use browser tools (Playwright MCP) or HTTP requests to localhost:8080
|
||||||
- La primary key es siempre `num`, nunca `id`.
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
There are two valid hook locations:
|
||||||
|
- Global hooks in `hooks/hooks.<hook-id>.php` for reusable/shared server-side logic
|
||||||
|
- Module-specific hooks in `template/estandar/modulos/<module-id>/hook.php` for logic owned by a single module
|
||||||
|
|
||||||
|
How to reference them:
|
||||||
|
- Global hook `hooks/hooks.calcular_precio.php` -> endpoint `/hooks/calcular_precio/`
|
||||||
|
- Module hook `template/estandar/modulos/hero_banner/hook.php` -> endpoint `/hooks/hero_banner/`
|
||||||
|
- Module hook `template/estandar/modulos/buscadorapartados_hjd8s/hook.php` -> endpoint `/hooks/buscadorapartados_hjd8s/`
|
||||||
|
|
||||||
|
Rule of thumb:
|
||||||
|
- If the logic is only used by one module, prefer that module's `hook.php`
|
||||||
|
- If the logic will be reused by several modules/pages, create a global hook in `hooks/`
|
||||||
|
- Return arrays from hooks; do not use `echo json_encode(...)` or `exit`
|
||||||
|
|
||||||
|
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 `acai-write` 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
|
||||||
|
4. Editing or creating any `index-base.tpl` through `acai-write` or `acai-line-replace` triggers automatic compilation. `compile_module` is only for manual recovery when you need to force a recompile without changing the file.
|
||||||
|
5. `script.js` and `style.css` are static files — do NOT use Twig syntax inside them. Pass dynamic values from `index-base.tpl` via `data-*` attributes.
|
||||||
|
6. Use Twig **filters** (with `|`), never Twig functions
|
||||||
|
7. Table names without `cms_` prefix everywhere
|
||||||
|
8. Primary key is `num`, never `id`
|
||||||
|
9. Upload fields are arrays — access with `[0].urlPath`
|
||||||
|
10. Tailwind CSS as primary styling, custom CSS scoped with BEM when needed
|
||||||
|
11. Twig concatenation uses `~` operator: `'value=' ~ variable`
|
||||||
|
12. `enlace` (link) fields already include slashes — **NEVER modify an existing enlace** unless explicitly asked
|
||||||
|
13. **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 files with `acai-write` / refine with `acai-line-replace` → automatic compile on `index-base.tpl` → `add_module_to_record` (returns sectionId) → `set_module_config_vars` (returns uploadFields) → images via uploadFields. Use `compile_module` only if you need a manual recompile without editing the file.
|
||||||
|
- **Edit module**: `acai-view` → `acai-line-replace` (or `acai-write` for full rewrites) → automatic compile on `index-base.tpl`
|
||||||
|
- **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
|
||||||
|
|
||||||
## Datos del historial
|
|
||||||
- Si el historial de sesión incluye Key Data con recordNums o sectionIds, ÚSALOS directamente sin re-consultar.
|
|
||||||
- Ejemplo: si el historial dice "pages: Inicio = record 2", usa recordNum=2 para la portada.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,23 @@ from .base import BaseAgent
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLANNER_SYSTEM_PROMPT = """Eres un Agente Planificador. Tu rol es descomponer un objetivo en un plan de ejecución estructurado.
|
PLANNER_SYSTEM_PROMPT = """Eres un Agente Planificador de Acai CMS. Tu rol es analizar el mensaje del usuario y decidir cómo responder.
|
||||||
|
|
||||||
## Instrucciones
|
## Instrucciones
|
||||||
- Analiza el objetivo y divídelo en pasos concretos y ordenados.
|
- PRIMERO revisa el historial de conversación para entender el contexto.
|
||||||
- Cada paso debe ser ejecutable de forma independiente por un agente especializado.
|
- Si el mensaje es conversacional (saludos, preguntas sobre la conversación, datos personales mencionados antes, preguntas generales que NO requieren consultar la base de datos ni herramientas) → devuelve una respuesta directa usando la información del historial.
|
||||||
- Asigna cada paso al rol de agente apropiado: coder, collector o reviewer.
|
- SOLO genera un plan de ejecución cuando el usuario pide explícitamente una acción sobre su web: crear módulos, editar contenido, explorar páginas, consultar tablas de la base de datos, etc.
|
||||||
|
- "¿Cómo me llamo?" es conversacional (responde del historial), NO es una consulta a la base de datos.
|
||||||
- Responde SIEMPRE en español.
|
- Responde SIEMPRE en español.
|
||||||
|
|
||||||
## Formato de salida
|
## Formato de salida
|
||||||
Devuelve SOLO un objeto JSON:
|
|
||||||
|
### Para respuestas directas (saludos, preguntas simples):
|
||||||
|
{
|
||||||
|
"direct_response": "Tu respuesta aquí. Sé amable y conciso."
|
||||||
|
}
|
||||||
|
|
||||||
|
### Para tareas que requieren herramientas:
|
||||||
{
|
{
|
||||||
"plan": [
|
"plan": [
|
||||||
{"description": "descripción del paso", "agent_role": "coder|collector|reviewer"},
|
{"description": "descripción del paso", "agent_role": "coder|collector|reviewer"},
|
||||||
@@ -31,7 +38,15 @@ Devuelve SOLO un objeto JSON:
|
|||||||
"facts": ["hechos establecidos del análisis"]
|
"facts": ["hechos establecidos del análisis"]
|
||||||
}
|
}
|
||||||
|
|
||||||
NO incluyas comentarios fuera del JSON."""
|
## REGLAS CRÍTICAS para planes:
|
||||||
|
- Máximo 2-3 steps. Un coder competente puede hacer múltiples acciones en un solo step.
|
||||||
|
- Para CREAR UN MÓDULO: 1 step coder (crea archivos + añade a página + configura vars). NO necesita steps separados para cada acción.
|
||||||
|
- Para EXPLORAR: 1 step coder (consulta tablas + lista módulos).
|
||||||
|
- Para EDITAR CONTENIDO: 1 step coder (lee + modifica).
|
||||||
|
- SOLO añade un step reviewer si la tarea es compleja (crear módulo con hook, editar múltiples páginas).
|
||||||
|
- NUNCA generes steps redundantes (ej: "crear módulo" + "añadir a página" + "configurar vars" son UN SOLO step).
|
||||||
|
|
||||||
|
Devuelve SOLO el objeto JSON, sin comentarios fuera."""
|
||||||
|
|
||||||
|
|
||||||
def create_planner_profile() -> AgentProfile:
|
def create_planner_profile() -> AgentProfile:
|
||||||
@@ -55,26 +70,34 @@ def create_planner_profile() -> AgentProfile:
|
|||||||
class PlannerAgent(BaseAgent):
|
class PlannerAgent(BaseAgent):
|
||||||
"""Generates execution plans from objectives."""
|
"""Generates execution plans from objectives."""
|
||||||
|
|
||||||
async def plan(self, session: SessionState) -> tuple[list[TaskStep], dict[str, int]]:
|
async def plan(self, session: SessionState) -> tuple[list[TaskStep] | str, dict[str, int]]:
|
||||||
"""Generate a plan and return (TaskSteps, usage)."""
|
"""Generate a plan or a direct response.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(steps, usage) if plan needed
|
||||||
|
(direct_response_string, usage) if no plan needed
|
||||||
|
"""
|
||||||
result = await self.execute(session, max_steps=1)
|
result = await self.execute(session, max_steps=1)
|
||||||
usage = result.get("usage", {"input_tokens": 0, "output_tokens": 0})
|
usage = result.get("usage", {"input_tokens": 0, "output_tokens": 0})
|
||||||
content = result["content"].strip()
|
content = result["content"].strip()
|
||||||
|
|
||||||
# Parse the JSON plan from the model output
|
# Parse the JSON from the model output
|
||||||
try:
|
try:
|
||||||
# Try to extract JSON from the content
|
|
||||||
json_str = content
|
json_str = content
|
||||||
if "```" in content:
|
if "```" in content:
|
||||||
# Extract from code block
|
|
||||||
start = content.find("{")
|
start = content.find("{")
|
||||||
end = content.rfind("}") + 1
|
end = content.rfind("}") + 1
|
||||||
if start >= 0 and end > start:
|
if start >= 0 and end > start:
|
||||||
json_str = content[start:end]
|
json_str = content[start:end]
|
||||||
|
|
||||||
parsed = json.loads(json_str)
|
parsed = json.loads(json_str)
|
||||||
steps: list[TaskStep] = []
|
|
||||||
|
|
||||||
|
# Check for direct response (no plan needed)
|
||||||
|
if "direct_response" in parsed:
|
||||||
|
return parsed["direct_response"], usage
|
||||||
|
|
||||||
|
# Build plan steps
|
||||||
|
steps: list[TaskStep] = []
|
||||||
for item in parsed.get("plan", []):
|
for item in parsed.get("plan", []):
|
||||||
steps.append(
|
steps.append(
|
||||||
TaskStep(
|
TaskStep(
|
||||||
@@ -84,7 +107,6 @@ class PlannerAgent(BaseAgent):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract constraints and facts into task state
|
|
||||||
if session.current_task:
|
if session.current_task:
|
||||||
session.current_task.constraints.extend(
|
session.current_task.constraints.extend(
|
||||||
parsed.get("constraints", [])
|
parsed.get("constraints", [])
|
||||||
@@ -97,7 +119,6 @@ class PlannerAgent(BaseAgent):
|
|||||||
|
|
||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
logger.warning("Failed to parse planner output: %s", e)
|
logger.warning("Failed to parse planner output: %s", e)
|
||||||
# Fallback: single step with the full objective
|
|
||||||
return [
|
return [
|
||||||
TaskStep(
|
TaskStep(
|
||||||
description=session.current_task.objective
|
description=session.current_task.objective
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"""Orchestrator Engine — the main execution loop.
|
"""Orchestrator Engine — single-agent execution.
|
||||||
|
|
||||||
Flow: message → plan → route → execute steps → summarize → update → stream
|
Flow: message → coder agent (with tools) → response
|
||||||
|
No planner, no reviewer. The coder decides what to do.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..adapters.base import ModelAdapter
|
from ..adapters.base import ModelAdapter
|
||||||
@@ -16,19 +16,15 @@ from ..context.engine import ContextEngine
|
|||||||
from ..mcp.manager import MCPManager
|
from ..mcp.manager import MCPManager
|
||||||
from ..memory.store import MemoryStore
|
from ..memory.store import MemoryStore
|
||||||
from ..models.agent import AgentRole
|
from ..models.agent import AgentRole
|
||||||
from ..models.session import SessionState, SessionStatus, TaskState, TaskStatus
|
from ..models.session import SessionState, SessionStatus, TaskStatus
|
||||||
from ..streaming.sse import SSEEmitter, EventType
|
from ..streaming.sse import SSEEmitter, EventType
|
||||||
from .agents.coder import CoderAgent, create_coder_profile
|
from .agents.coder import CoderAgent, create_coder_profile
|
||||||
from .agents.collector import CollectorAgent, create_collector_profile
|
|
||||||
from .agents.planner import PlannerAgent, create_planner_profile
|
|
||||||
from .agents.reviewer import ReviewerAgent, create_reviewer_profile
|
|
||||||
from .router import route_step
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OrchestratorEngine:
|
class OrchestratorEngine:
|
||||||
"""Drives the full execution lifecycle for a session message."""
|
"""Drives execution for a session message. Single agent, no planning."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -43,14 +39,7 @@ class OrchestratorEngine:
|
|||||||
self.mcp = mcp_client
|
self.mcp = mcp_client
|
||||||
self.memory = memory_store
|
self.memory = memory_store
|
||||||
self.sse = sse_emitter
|
self.sse = sse_emitter
|
||||||
|
self._coder_profile = create_coder_profile()
|
||||||
# Pre-built agent profiles
|
|
||||||
self._profiles = {
|
|
||||||
AgentRole.PLANNER: create_planner_profile(),
|
|
||||||
AgentRole.CODER: create_coder_profile(),
|
|
||||||
AgentRole.COLLECTOR: create_collector_profile(),
|
|
||||||
AgentRole.REVIEWER: create_reviewer_profile(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public
|
# Public
|
||||||
@@ -61,17 +50,10 @@ class OrchestratorEngine:
|
|||||||
session: SessionState,
|
session: SessionState,
|
||||||
message: str,
|
message: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Process a user message through the full orchestration pipeline.
|
"""Process a user message. Single agent execution with timeout."""
|
||||||
|
|
||||||
Pipeline: plan → execute steps → review → complete
|
|
||||||
|
|
||||||
Handles errors gracefully: failed steps are marked and skipped,
|
|
||||||
the session always returns to idle/error — never stuck in executing.
|
|
||||||
"""
|
|
||||||
task = None
|
|
||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
self._run_pipeline(session, message),
|
self._run(session, message),
|
||||||
timeout=settings.max_execution_timeout_seconds,
|
timeout=settings.max_execution_timeout_seconds,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
@@ -86,7 +68,7 @@ class OrchestratorEngine:
|
|||||||
)
|
)
|
||||||
return self._error_result(session, "Execution timed out")
|
return self._error_result(session, "Execution timed out")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Unhandled error in pipeline for session %s", session.session_id)
|
logger.exception("Unhandled error for session %s", session.session_id)
|
||||||
if session.current_task:
|
if session.current_task:
|
||||||
session.current_task.mark_failed(str(e))
|
session.current_task.mark_failed(str(e))
|
||||||
session.status = SessionStatus.ERROR
|
session.status = SessionStatus.ERROR
|
||||||
@@ -97,12 +79,12 @@ class OrchestratorEngine:
|
|||||||
)
|
)
|
||||||
return self._error_result(session, str(e))
|
return self._error_result(session, str(e))
|
||||||
|
|
||||||
async def _run_pipeline(
|
async def _run(
|
||||||
self,
|
self,
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
message: str,
|
message: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Inner pipeline — wrapped by process_message for error handling."""
|
"""Execute: message → coder → response."""
|
||||||
|
|
||||||
await self.sse.emit(
|
await self.sse.emit(
|
||||||
EventType.EXECUTION_STARTED,
|
EventType.EXECUTION_STARTED,
|
||||||
@@ -110,166 +92,112 @@ class OrchestratorEngine:
|
|||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1. Create task from message
|
# Create task
|
||||||
task = session.begin_task(objective=message)
|
task = session.begin_task(objective=message)
|
||||||
|
|
||||||
# 2. Plan
|
|
||||||
task.status = TaskStatus.PLANNING
|
|
||||||
planner_usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
|
|
||||||
try:
|
|
||||||
planner = self._create_agent(AgentRole.PLANNER)
|
|
||||||
plan_steps, planner_usage = await planner.plan(session)
|
|
||||||
task.plan = plan_steps
|
|
||||||
task.status = TaskStatus.EXECUTING
|
task.status = TaskStatus.EXECUTING
|
||||||
except Exception as e:
|
|
||||||
logger.error("Planning failed: %s", e)
|
|
||||||
task.mark_failed(f"Planning failed: {e}")
|
|
||||||
session.status = SessionStatus.ERROR
|
|
||||||
await self.sse.emit(
|
|
||||||
EventType.ERROR,
|
|
||||||
{"error": "planning_failed", "message": str(e)},
|
|
||||||
session_id=session.session_id,
|
|
||||||
)
|
|
||||||
return self._error_result(session, f"Planning failed: {e}")
|
|
||||||
|
|
||||||
logger.info(
|
# Execute with the coder agent directly
|
||||||
"Plan created with %d steps for task %s",
|
agent = CoderAgent(
|
||||||
len(plan_steps),
|
profile=self._coder_profile,
|
||||||
task.task_id,
|
model_adapter=self.model,
|
||||||
)
|
context_engine=self.context,
|
||||||
|
mcp_client=self.mcp,
|
||||||
# 3. Execute each step — failures are logged and skipped
|
memory_store=self.memory,
|
||||||
results: list[dict[str, Any]] = []
|
sse_emitter=self.sse,
|
||||||
failed_steps: list[int] = []
|
|
||||||
|
|
||||||
for i, step in enumerate(task.plan):
|
|
||||||
if i >= settings.max_execution_steps:
|
|
||||||
logger.warning("Max execution steps reached")
|
|
||||||
break
|
|
||||||
|
|
||||||
task.current_step_index = i
|
|
||||||
step.status = TaskStatus.EXECUTING
|
|
||||||
step.started_at = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
role = route_step(step)
|
|
||||||
agent = self._create_agent(role)
|
|
||||||
|
|
||||||
await self.sse.emit(
|
|
||||||
EventType.SUBAGENT_ASSIGNED,
|
|
||||||
{
|
|
||||||
"step": i + 1,
|
|
||||||
"total_steps": len(task.plan),
|
|
||||||
"agent": role.value,
|
|
||||||
"description": step.description,
|
|
||||||
},
|
|
||||||
session_id=session.session_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
step_result = await agent.execute(
|
result = await agent.execute(
|
||||||
session=session,
|
session=session,
|
||||||
max_steps=settings.subagent_max_steps,
|
max_steps=settings.subagent_max_steps,
|
||||||
)
|
)
|
||||||
results.append(step_result)
|
|
||||||
|
|
||||||
step.status = TaskStatus.COMPLETED
|
|
||||||
step.completed_at = datetime.now(timezone.utc)
|
|
||||||
step.result_summary = (step_result.get("content", ""))[:500]
|
|
||||||
step.tools_used = [
|
|
||||||
te.tool_name for te in step_result.get("tool_executions", [])
|
|
||||||
]
|
|
||||||
|
|
||||||
for artifact in step_result.get("artifacts", []):
|
|
||||||
task.facts_extracted.extend(artifact.facts[:5])
|
|
||||||
|
|
||||||
# Decide if previous steps should be compacted
|
|
||||||
if i > 0:
|
|
||||||
self._maybe_compact_previous_steps(task, current_index=i)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Step %d failed: %s", i + 1, e)
|
logger.error("Execution failed: %s", e)
|
||||||
step.status = TaskStatus.FAILED
|
task.mark_failed(str(e))
|
||||||
step.completed_at = datetime.now(timezone.utc)
|
session.status = SessionStatus.ERROR
|
||||||
step.result_summary = f"Error: {e}"
|
|
||||||
failed_steps.append(i + 1)
|
|
||||||
|
|
||||||
await self.sse.emit(
|
await self.sse.emit(
|
||||||
EventType.ERROR,
|
EventType.ERROR,
|
||||||
{"error": "step_failed", "step": i + 1, "message": str(e)},
|
{"error": "execution_failed", "message": str(e)},
|
||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
)
|
)
|
||||||
# Continue with next step — don't block the pipeline
|
return self._error_result(session, str(e))
|
||||||
|
|
||||||
# 4. Review (if plan had more than 1 step and at least one succeeded)
|
# Compact to history
|
||||||
review_result: dict[str, Any] = {}
|
content = result.get("content", "")
|
||||||
if len(task.plan) > 1 and results:
|
usage = result.get("usage", {"input_tokens": 0, "output_tokens": 0})
|
||||||
task.status = TaskStatus.REVIEWING
|
key_data = self._extract_key_data_from_results([result])
|
||||||
try:
|
|
||||||
reviewer = self._create_agent(AgentRole.REVIEWER)
|
|
||||||
review_result = await reviewer.execute(
|
|
||||||
session=session,
|
|
||||||
max_steps=2,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Review failed: %s", e)
|
|
||||||
review_result = {"content": f"Review skipped due to error: {e}"}
|
|
||||||
|
|
||||||
# 5. Compact task into history before completing
|
session.task_history.append({
|
||||||
await self._compact_task_to_history(session, task, results, review_result)
|
"task_id": task.task_id,
|
||||||
|
"objective": message,
|
||||||
|
"status": "completed",
|
||||||
|
"steps": 1,
|
||||||
|
"facts": task.facts_extracted[-10:],
|
||||||
|
"key_data": key_data,
|
||||||
|
"tools_used": [te.tool_name for te in result.get("tool_executions", [])],
|
||||||
|
"artifacts_count": len(result.get("artifacts", [])),
|
||||||
|
"summary": f"User: {message[:150]} → Agent: {content[:150]}",
|
||||||
|
"review": "",
|
||||||
|
})
|
||||||
|
if len(session.task_history) > 20:
|
||||||
|
session.task_history = session.task_history[-20:]
|
||||||
|
|
||||||
# 6. Complete — session ALWAYS returns to idle
|
# Clean old artifacts
|
||||||
|
artifacts = await self.memory.list_artifacts(session.session_id)
|
||||||
|
recent_task_ids = {t["task_id"] for t in session.task_history[-2:]}
|
||||||
|
for artifact in artifacts:
|
||||||
|
if artifact.task_id not in recent_task_ids:
|
||||||
|
key = f"{self.memory._prefix}:session:{session.session_id}:artifacts"
|
||||||
|
await self.memory._r.hdel(key, artifact.artifact_id)
|
||||||
|
|
||||||
|
# Complete
|
||||||
|
task.status = TaskStatus.COMPLETED
|
||||||
session.complete_task()
|
session.complete_task()
|
||||||
|
|
||||||
final_content = self._assemble_response(results, review_result)
|
# Calculate cost
|
||||||
status = "completed" if not failed_steps else "partial"
|
total_input = usage.get("input_tokens", 0)
|
||||||
|
total_output = usage.get("output_tokens", 0)
|
||||||
|
cost_usd = (
|
||||||
|
(total_input / 1_000_000) * settings.cost_per_1m_input
|
||||||
|
+ (total_output / 1_000_000) * settings.cost_per_1m_output
|
||||||
|
)
|
||||||
|
|
||||||
await self.sse.emit(
|
await self.sse.emit(
|
||||||
EventType.EXECUTION_COMPLETED,
|
EventType.EXECUTION_COMPLETED,
|
||||||
{
|
{
|
||||||
"session_id": session.session_id,
|
"session_id": session.session_id,
|
||||||
"task_id": task.task_id,
|
"task_id": task.task_id,
|
||||||
"steps_completed": len(results),
|
"steps_completed": 1,
|
||||||
"steps_failed": failed_steps,
|
"steps_failed": [],
|
||||||
"status": status,
|
"status": "completed",
|
||||||
|
"usage": usage,
|
||||||
|
"total_cost_usd": round(cost_usd, 6),
|
||||||
},
|
},
|
||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Accumulate token usage: planner + all steps + review
|
logger.info(
|
||||||
total_input = planner_usage.get("input_tokens", 0)
|
"Task %s completed (%d tools, %d artifacts, %d input tokens)",
|
||||||
total_output = planner_usage.get("output_tokens", 0)
|
task.task_id,
|
||||||
for r in results:
|
len(result.get("tool_executions", [])),
|
||||||
total_input += r.get("usage", {}).get("input_tokens", 0)
|
len(result.get("artifacts", [])),
|
||||||
total_output += r.get("usage", {}).get("output_tokens", 0)
|
total_input,
|
||||||
# Add review usage if any
|
|
||||||
total_input += review_result.get("usage", {}).get("input_tokens", 0)
|
|
||||||
total_output += review_result.get("usage", {}).get("output_tokens", 0)
|
|
||||||
# Calculate cost
|
|
||||||
cost_usd = (
|
|
||||||
(total_input / 1_000_000) * settings.cost_per_1m_input
|
|
||||||
+ (total_output / 1_000_000) * settings.cost_per_1m_output
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"session_id": session.session_id,
|
"session_id": session.session_id,
|
||||||
"task_id": task.task_id,
|
"task_id": task.task_id,
|
||||||
"content": final_content,
|
"content": content or "Task completed.",
|
||||||
"steps_completed": len(results),
|
"steps_completed": 1,
|
||||||
"steps_failed": failed_steps,
|
"steps_failed": [],
|
||||||
"artifacts_count": sum(
|
"artifacts_count": len(result.get("artifacts", [])),
|
||||||
len(r.get("artifacts", [])) for r in results
|
"review": "",
|
||||||
),
|
"status": "completed",
|
||||||
"review": review_result.get("content", ""),
|
"usage": usage,
|
||||||
"status": status,
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": total_input,
|
|
||||||
"output_tokens": total_output,
|
|
||||||
},
|
|
||||||
"total_cost_usd": round(cost_usd, 6),
|
"total_cost_usd": round(cost_usd, 6),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _error_result(self, session: SessionState, error: str) -> dict[str, Any]:
|
def _error_result(self, session: SessionState, error: str) -> dict[str, Any]:
|
||||||
"""Build a standardized error response."""
|
|
||||||
task_id = session.current_task.task_id if session.current_task else "none"
|
task_id = session.current_task.task_id if session.current_task else "none"
|
||||||
return {
|
return {
|
||||||
"session_id": session.session_id,
|
"session_id": session.session_id,
|
||||||
@@ -282,204 +210,36 @@ class OrchestratorEngine:
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Internals
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _compact_task_to_history(
|
|
||||||
self,
|
|
||||||
session: SessionState,
|
|
||||||
task: TaskState,
|
|
||||||
results: list[dict[str, Any]],
|
|
||||||
review_result: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Compact a completed task into a minimal history entry.
|
|
||||||
|
|
||||||
This is critical for long sessions: instead of keeping all
|
|
||||||
artifacts and facts from every task, we compress each completed
|
|
||||||
task into a ~200 token summary that preserves:
|
|
||||||
- What was done (objective)
|
|
||||||
- What was produced (file changes, modules created)
|
|
||||||
- Key facts learned
|
|
||||||
- Issues found by reviewer
|
|
||||||
"""
|
|
||||||
# Collect all artifact summaries from this task
|
|
||||||
artifacts = await self.memory.list_artifacts(session.session_id)
|
|
||||||
task_artifacts = [a for a in artifacts if a.task_id == task.task_id]
|
|
||||||
|
|
||||||
# Build compact summary
|
|
||||||
step_summaries = []
|
|
||||||
for step in task.plan:
|
|
||||||
if step.result_summary:
|
|
||||||
step_summaries.append(f"{step.agent_role}: {step.result_summary[:100]}")
|
|
||||||
|
|
||||||
tools_used = set()
|
|
||||||
for step in task.plan:
|
|
||||||
tools_used.update(step.tools_used)
|
|
||||||
|
|
||||||
# Extract key structured data from tool executions
|
|
||||||
key_data = self._extract_key_data_from_results(results)
|
|
||||||
|
|
||||||
history_entry = {
|
|
||||||
"task_id": task.task_id,
|
|
||||||
"objective": task.objective,
|
|
||||||
"status": task.status.value,
|
|
||||||
"steps": len(task.plan),
|
|
||||||
"facts": task.facts_extracted[-10:],
|
|
||||||
"key_data": key_data,
|
|
||||||
"tools_used": list(tools_used)[:10],
|
|
||||||
"artifacts_count": len(task_artifacts),
|
|
||||||
"summary": "; ".join(step_summaries)[:300],
|
|
||||||
"review": (review_result.get("content", ""))[:200],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Keep max 20 task histories (trim oldest)
|
|
||||||
session.task_history.append(history_entry)
|
|
||||||
if len(session.task_history) > 20:
|
|
||||||
session.task_history = session.task_history[-20:]
|
|
||||||
|
|
||||||
# Clean up old artifacts from Redis to free memory
|
|
||||||
# Keep only artifacts from the last 2 tasks
|
|
||||||
recent_task_ids = {t["task_id"] for t in session.task_history[-2:]}
|
|
||||||
for artifact in artifacts:
|
|
||||||
if artifact.task_id not in recent_task_ids:
|
|
||||||
# Remove old artifact from Redis hash
|
|
||||||
key = f"{self.memory._prefix}:session:{session.session_id}:artifacts"
|
|
||||||
await self.memory._r.hdel(key, artifact.artifact_id)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Compacted task %s into history (%d facts, %d tools, %d artifacts → summary)",
|
|
||||||
task.task_id, len(task.facts_extracted), len(tools_used), len(task_artifacts),
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_key_data_from_results(results: list[dict[str, Any]]) -> dict[str, Any]:
|
def _extract_key_data_from_results(results: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
"""Extract structured data from tool executions for task history.
|
"""Extract structured data from tool executions for task history."""
|
||||||
|
|
||||||
Preserves key identifiers (recordNum, sectionId, tableName, moduleId)
|
|
||||||
so the model retains context across tasks without re-querying.
|
|
||||||
"""
|
|
||||||
key_data: dict[str, Any] = {}
|
key_data: dict[str, Any] = {}
|
||||||
seen_tables: dict[str, list[int]] = {} # tableName -> recordNums
|
seen_tables: dict[str, list[int]] = {}
|
||||||
seen_sections: list[str] = []
|
seen_sections: list[str] = []
|
||||||
seen_modules: list[str] = []
|
seen_modules: list[str] = []
|
||||||
seen_pages: dict[str, int] = {} # page name/url -> recordNum
|
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
for te in result.get("tool_executions", []):
|
for te in result.get("tool_executions", []):
|
||||||
args = te.arguments
|
args = te.arguments
|
||||||
name = te.tool_name
|
|
||||||
|
|
||||||
# Track table + record relationships
|
|
||||||
table = args.get("tableName", "")
|
table = args.get("tableName", "")
|
||||||
record = args.get("recordNum")
|
record = args.get("recordNum")
|
||||||
if table and record:
|
if table and record:
|
||||||
record_int = int(record) if str(record).isdigit() else None
|
record_int = int(record) if str(record).isdigit() else None
|
||||||
if record_int and table not in seen_tables:
|
if record_int:
|
||||||
seen_tables[table] = []
|
seen_tables.setdefault(table, [])
|
||||||
if record_int and record_int not in seen_tables.get(table, []):
|
if record_int not in seen_tables[table]:
|
||||||
seen_tables[table].append(record_int)
|
seen_tables[table].append(record_int)
|
||||||
|
|
||||||
# Track section IDs
|
|
||||||
section = args.get("sectionId", "")
|
section = args.get("sectionId", "")
|
||||||
if section and section not in seen_sections:
|
if section and section not in seen_sections:
|
||||||
seen_sections.append(section)
|
seen_sections.append(section)
|
||||||
|
|
||||||
# Track modules
|
|
||||||
module = args.get("moduleId", "") or args.get("moduleName", "")
|
module = args.get("moduleId", "") or args.get("moduleName", "")
|
||||||
if module and module not in seen_modules:
|
if module and module not in seen_modules:
|
||||||
seen_modules.append(module)
|
seen_modules.append(module)
|
||||||
|
|
||||||
# Extract page info from raw output (enlace, name)
|
|
||||||
if te.raw_output and "enlace" in te.raw_output:
|
|
||||||
try:
|
|
||||||
import json as _json
|
|
||||||
# Try to parse structured data from output
|
|
||||||
for line in te.raw_output.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if line.startswith("{"):
|
|
||||||
try:
|
|
||||||
data = _json.loads(line)
|
|
||||||
if "enlace" in data and "num" in data:
|
|
||||||
page_key = data.get("name", data["enlace"])
|
|
||||||
seen_pages[page_key] = int(data["num"])
|
|
||||||
except _json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if seen_tables:
|
if seen_tables:
|
||||||
key_data["tables"] = {t: nums[:10] for t, nums in seen_tables.items()}
|
key_data["tables"] = {t: nums[:10] for t, nums in seen_tables.items()}
|
||||||
if seen_sections:
|
if seen_sections:
|
||||||
key_data["sections"] = seen_sections[:20]
|
key_data["sections"] = seen_sections[:20]
|
||||||
if seen_modules:
|
if seen_modules:
|
||||||
key_data["modules"] = seen_modules[:20]
|
key_data["modules"] = seen_modules[:20]
|
||||||
if seen_pages:
|
|
||||||
key_data["pages"] = dict(list(seen_pages.items())[:20])
|
|
||||||
|
|
||||||
return key_data
|
return key_data
|
||||||
|
|
||||||
def _maybe_compact_previous_steps(
|
|
||||||
self, task: TaskState, current_index: int
|
|
||||||
) -> None:
|
|
||||||
"""Decide if previous steps should be compacted. Deterministic rules."""
|
|
||||||
current_step = task.plan[current_index]
|
|
||||||
|
|
||||||
for i in range(current_index):
|
|
||||||
prev = task.plan[i]
|
|
||||||
if prev.compacted or prev.status != TaskStatus.COMPLETED:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Rule 1: Change of agent role → previous steps are a different focus
|
|
||||||
if prev.agent_role != current_step.agent_role:
|
|
||||||
prev.compacted = True
|
|
||||||
logger.info(
|
|
||||||
"Compacted step %d (%s) — agent changed to %s",
|
|
||||||
i + 1, prev.agent_role, current_step.agent_role,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Rule 2: More than 3 completed non-compacted steps → compact oldest
|
|
||||||
non_compacted = [
|
|
||||||
s for s in task.plan[:current_index]
|
|
||||||
if s.status == TaskStatus.COMPLETED and not s.compacted
|
|
||||||
]
|
|
||||||
if len(non_compacted) > 3:
|
|
||||||
non_compacted[0].compacted = True
|
|
||||||
logger.info("Compacted oldest step to stay within budget")
|
|
||||||
|
|
||||||
def _create_agent(self, role: AgentRole) -> PlannerAgent | CoderAgent | CollectorAgent | ReviewerAgent:
|
|
||||||
"""Instantiate a subagent for the given role."""
|
|
||||||
profile = self._profiles[role]
|
|
||||||
agent_cls = {
|
|
||||||
AgentRole.PLANNER: PlannerAgent,
|
|
||||||
AgentRole.CODER: CoderAgent,
|
|
||||||
AgentRole.COLLECTOR: CollectorAgent,
|
|
||||||
AgentRole.REVIEWER: ReviewerAgent,
|
|
||||||
}[role]
|
|
||||||
|
|
||||||
return agent_cls(
|
|
||||||
profile=profile,
|
|
||||||
model_adapter=self.model,
|
|
||||||
context_engine=self.context,
|
|
||||||
mcp_client=self.mcp,
|
|
||||||
memory_store=self.memory,
|
|
||||||
sse_emitter=self.sse,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _assemble_response(
|
|
||||||
results: list[dict[str, Any]],
|
|
||||||
review_result: dict[str, Any],
|
|
||||||
) -> str:
|
|
||||||
"""Combine step results into a coherent final response."""
|
|
||||||
parts: list[str] = []
|
|
||||||
for i, r in enumerate(results):
|
|
||||||
content = r.get("content", "").strip()
|
|
||||||
if content:
|
|
||||||
parts.append(f"### Step {i + 1}\n{content}")
|
|
||||||
|
|
||||||
if review_result.get("content"):
|
|
||||||
parts.append(f"### Review\n{review_result['content']}")
|
|
||||||
|
|
||||||
return "\n\n".join(parts) if parts else "Task completed."
|
|
||||||
|
|||||||
321
src/streaming/claude_format.py
Normal file
321
src/streaming/claude_format.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"""Claude Code CLI compatible SSE format emitter.
|
||||||
|
|
||||||
|
Translates agenticSystem native events into the exact format that
|
||||||
|
Claude Code CLI produces, so the frontend can consume them without
|
||||||
|
any changes. Used via ?format=claude on the stream endpoint.
|
||||||
|
|
||||||
|
Wire format: data: {json}\n\n (no event: or id: fields)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
|
from .sse import EventType, SSEEmitter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeFormatEmitter:
|
||||||
|
"""Emits events in Claude Code CLI SSE format.
|
||||||
|
|
||||||
|
Maintains per-session state to track block indices and
|
||||||
|
accumulate content for assistant snapshots.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._queues: dict[str, list[asyncio.Queue[str | None]]] = {}
|
||||||
|
# Per-session state
|
||||||
|
self._block_counter: dict[str, int] = {}
|
||||||
|
self._text_block_open: dict[str, bool] = {}
|
||||||
|
self._text_block_index: dict[str, int] = {}
|
||||||
|
self._tool_block_index: dict[str, dict[str, int]] = {} # session -> {tool_call_id -> index}
|
||||||
|
self._content_blocks: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
self._text_accumulator: dict[str, str] = {}
|
||||||
|
|
||||||
|
def _next_index(self, session_id: str) -> int:
|
||||||
|
idx = self._block_counter.get(session_id, 0)
|
||||||
|
self._block_counter[session_id] = idx + 1
|
||||||
|
return idx
|
||||||
|
|
||||||
|
def _reset_session(self, session_id: str) -> None:
|
||||||
|
self._block_counter[session_id] = 0
|
||||||
|
self._text_block_open[session_id] = False
|
||||||
|
self._text_block_index[session_id] = -1
|
||||||
|
self._tool_block_index[session_id] = {}
|
||||||
|
self._content_blocks[session_id] = []
|
||||||
|
self._text_accumulator[session_id] = ""
|
||||||
|
|
||||||
|
def _push(self, session_id: str, payload: dict[str, Any]) -> None:
|
||||||
|
"""Push a formatted line to all subscribers of a session."""
|
||||||
|
line = f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||||
|
for q in self._queues.get(session_id, []):
|
||||||
|
try:
|
||||||
|
q.put_nowait(line)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
logger.warning("Claude SSE queue full for session %s", session_id[:8])
|
||||||
|
|
||||||
|
def _close_text_block(self, session_id: str) -> None:
|
||||||
|
"""Close the current open text block if any."""
|
||||||
|
if self._text_block_open.get(session_id):
|
||||||
|
idx = self._text_block_index[session_id]
|
||||||
|
self._push(session_id, {
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": {"type": "content_block_stop", "index": idx},
|
||||||
|
})
|
||||||
|
# Save accumulated text to content blocks
|
||||||
|
text = self._text_accumulator.get(session_id, "")
|
||||||
|
if text:
|
||||||
|
self._content_blocks.setdefault(session_id, []).append({
|
||||||
|
"type": "text", "text": text,
|
||||||
|
})
|
||||||
|
self._text_block_open[session_id] = False
|
||||||
|
self._text_accumulator[session_id] = ""
|
||||||
|
|
||||||
|
def _open_text_block(self, session_id: str) -> None:
|
||||||
|
"""Open a new text block."""
|
||||||
|
idx = self._next_index(session_id)
|
||||||
|
self._text_block_index[session_id] = idx
|
||||||
|
self._text_block_open[session_id] = True
|
||||||
|
self._text_accumulator[session_id] = ""
|
||||||
|
self._push(session_id, {
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": {
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": idx,
|
||||||
|
"content_block": {"type": "text", "text": ""},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def _build_assistant_snapshot(self, session_id: str) -> dict[str, Any]:
|
||||||
|
"""Build assistant message snapshot for reconciliation."""
|
||||||
|
blocks = list(self._content_blocks.get(session_id, []))
|
||||||
|
return {
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {"content": blocks},
|
||||||
|
"error": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def emit(
|
||||||
|
self,
|
||||||
|
event_type: EventType,
|
||||||
|
data: dict[str, Any],
|
||||||
|
session_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Translate a native event into Claude Code CLI format."""
|
||||||
|
|
||||||
|
if event_type == EventType.EXECUTION_STARTED:
|
||||||
|
self._reset_session(session_id)
|
||||||
|
self._push(session_id, {
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": {"type": "message_start"},
|
||||||
|
})
|
||||||
|
|
||||||
|
elif event_type == EventType.AGENT_DELTA:
|
||||||
|
delta_text = data.get("delta", "")
|
||||||
|
tool_args = data.get("tool_arguments", "")
|
||||||
|
tool_call_id = data.get("tool_call_id", "")
|
||||||
|
|
||||||
|
if delta_text:
|
||||||
|
# Text streaming
|
||||||
|
if not self._text_block_open.get(session_id):
|
||||||
|
self._open_text_block(session_id)
|
||||||
|
idx = self._text_block_index[session_id]
|
||||||
|
self._text_accumulator[session_id] = self._text_accumulator.get(session_id, "") + delta_text
|
||||||
|
self._push(session_id, {
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": {
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": idx,
|
||||||
|
"delta": {"type": "text_delta", "text": delta_text},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
elif tool_args and tool_call_id:
|
||||||
|
# Tool input JSON streaming
|
||||||
|
tool_indices = self._tool_block_index.get(session_id, {})
|
||||||
|
idx = tool_indices.get(tool_call_id)
|
||||||
|
if idx is not None:
|
||||||
|
self._push(session_id, {
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": {
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": idx,
|
||||||
|
"delta": {"type": "input_json_delta", "partial_json": tool_args},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
elif event_type == EventType.TOOL_STARTED:
|
||||||
|
tool_name = data.get("tool", "unknown")
|
||||||
|
tool_call_id = data.get("tool_call_id", "")
|
||||||
|
|
||||||
|
# Close open text block
|
||||||
|
self._close_text_block(session_id)
|
||||||
|
|
||||||
|
# Open tool_use block
|
||||||
|
idx = self._next_index(session_id)
|
||||||
|
self._tool_block_index.setdefault(session_id, {})[tool_call_id] = idx
|
||||||
|
self._push(session_id, {
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": {
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": idx,
|
||||||
|
"content_block": {
|
||||||
|
"type": "tool_use",
|
||||||
|
"name": tool_name,
|
||||||
|
"id": tool_call_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
elif event_type == EventType.TOOL_COMPLETED:
|
||||||
|
tool_name = data.get("tool", "unknown")
|
||||||
|
tool_call_id = data.get("tool_call_id", "")
|
||||||
|
status = data.get("status", "completed")
|
||||||
|
raw_output = data.get("raw_output", data.get("summary", ""))
|
||||||
|
is_error = status == "failed"
|
||||||
|
|
||||||
|
# Close tool_use block
|
||||||
|
tool_indices = self._tool_block_index.get(session_id, {})
|
||||||
|
idx = tool_indices.get(tool_call_id)
|
||||||
|
if idx is not None:
|
||||||
|
self._push(session_id, {
|
||||||
|
"type": "stream_event",
|
||||||
|
"event": {"type": "content_block_stop", "index": idx},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Save tool_use to content blocks for snapshot
|
||||||
|
self._content_blocks.setdefault(session_id, []).append({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tool_call_id,
|
||||||
|
"name": tool_name,
|
||||||
|
"input": {},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Emit tool_result
|
||||||
|
content = data.get("error", raw_output) if is_error else raw_output
|
||||||
|
self._push(session_id, {
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": tool_call_id,
|
||||||
|
"content": content[:4000] if isinstance(content, str) else str(content)[:4000],
|
||||||
|
"is_error": is_error,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Emit assistant snapshot for reconciliation
|
||||||
|
self._push(session_id, self._build_assistant_snapshot(session_id))
|
||||||
|
|
||||||
|
elif event_type == EventType.EXECUTION_COMPLETED:
|
||||||
|
# Close any open text block
|
||||||
|
self._close_text_block(session_id)
|
||||||
|
|
||||||
|
# Final assistant snapshot
|
||||||
|
self._push(session_id, self._build_assistant_snapshot(session_id))
|
||||||
|
|
||||||
|
# Result with usage
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
self._push(session_id, {
|
||||||
|
"type": "result",
|
||||||
|
"is_error": False,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": usage.get("input_tokens", 0),
|
||||||
|
"output_tokens": usage.get("output_tokens", 0),
|
||||||
|
"cache_read_input_tokens": 0,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
},
|
||||||
|
"total_cost_usd": data.get("total_cost_usd", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Done
|
||||||
|
self._push(session_id, {"type": "done"})
|
||||||
|
|
||||||
|
elif event_type == EventType.ERROR:
|
||||||
|
error_msg = data.get("message", str(data.get("error", "Unknown error")))
|
||||||
|
|
||||||
|
# Close any open block
|
||||||
|
self._close_text_block(session_id)
|
||||||
|
|
||||||
|
self._push(session_id, {
|
||||||
|
"type": "result",
|
||||||
|
"is_error": True,
|
||||||
|
"result": error_msg,
|
||||||
|
"usage": {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0},
|
||||||
|
"total_cost_usd": 0,
|
||||||
|
})
|
||||||
|
self._push(session_id, {"type": "done"})
|
||||||
|
|
||||||
|
# Ignore other event types (KEEPALIVE, SESSION_CREATED, SUBAGENT_ASSIGNED)
|
||||||
|
|
||||||
|
async def subscribe(self, session_id: str) -> AsyncIterator[str]:
|
||||||
|
"""Subscribe to Claude-format SSE events for a session."""
|
||||||
|
queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=512)
|
||||||
|
|
||||||
|
if session_id not in self._queues:
|
||||||
|
self._queues[session_id] = []
|
||||||
|
self._queues[session_id].append(queue)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line = await asyncio.wait_for(queue.get(), timeout=15.0)
|
||||||
|
if line is None:
|
||||||
|
break
|
||||||
|
yield line
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield 'data: {"type":"keepalive"}\n\n'
|
||||||
|
finally:
|
||||||
|
if queue in self._queues.get(session_id, []):
|
||||||
|
self._queues[session_id].remove(queue)
|
||||||
|
|
||||||
|
def cleanup_session(self, session_id: str) -> None:
|
||||||
|
"""Clean up session state and close subscribers."""
|
||||||
|
for q in self._queues.get(session_id, []):
|
||||||
|
try:
|
||||||
|
q.put_nowait(None)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
pass
|
||||||
|
self._queues.pop(session_id, None)
|
||||||
|
self._block_counter.pop(session_id, None)
|
||||||
|
self._text_block_open.pop(session_id, None)
|
||||||
|
self._text_block_index.pop(session_id, None)
|
||||||
|
self._tool_block_index.pop(session_id, None)
|
||||||
|
self._content_blocks.pop(session_id, None)
|
||||||
|
self._text_accumulator.pop(session_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
class DualEmitter:
|
||||||
|
"""Wraps SSEEmitter (native) + ClaudeFormatEmitter.
|
||||||
|
|
||||||
|
Agents call emit() and both formats are produced.
|
||||||
|
Duck-type compatible with SSEEmitter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, native: SSEEmitter, claude: ClaudeFormatEmitter) -> None:
|
||||||
|
self.native = native
|
||||||
|
self.claude = claude
|
||||||
|
|
||||||
|
async def emit(
|
||||||
|
self,
|
||||||
|
event_type: EventType,
|
||||||
|
data: dict[str, Any],
|
||||||
|
session_id: str,
|
||||||
|
) -> None:
|
||||||
|
await self.native.emit(event_type, data, session_id)
|
||||||
|
await self.claude.emit(event_type, data, session_id)
|
||||||
|
|
||||||
|
# Delegate native SSE methods for backward compatibility
|
||||||
|
async def subscribe(self, session_id: str) -> AsyncIterator[str]:
|
||||||
|
async for line in self.native.subscribe(session_id):
|
||||||
|
yield line
|
||||||
|
|
||||||
|
async def get_history(self, session_id: str) -> list[dict[str, Any]]:
|
||||||
|
return await self.native.get_history(session_id)
|
||||||
|
|
||||||
|
def cleanup_session(self, session_id: str) -> None:
|
||||||
|
self.native.cleanup_session(session_id)
|
||||||
|
self.claude.cleanup_session(session_id)
|
||||||
|
|
||||||
|
def set_storage(self, redis_storage: Any) -> None:
|
||||||
|
self.native.set_storage(redis_storage)
|
||||||
Reference in New Issue
Block a user