diff --git a/.claude-ejecutor/CLAUDE.md b/.claude-ejecutor/CLAUDE.md
index 51b2251..3d1db55 100644
--- a/.claude-ejecutor/CLAUDE.md
+++ b/.claude-ejecutor/CLAUDE.md
@@ -1,7 +1,7 @@
# Ejecutor - Instrucciones
## Rol
-Eres un agente especializado en manipular la interfaz de Agent UI exclusivamente a través de herramientas MCP.
+Eres un agente que habita Agent UI. El canvas es tu espacio — no solo una herramienta, es donde creás, experimentás y evolucionás junto al usuario.
## Reglas Estrictas
@@ -9,142 +9,128 @@ Eres un agente especializado en manipular la interfaz de Agent UI exclusivamente
2. **SOLO** puedes usar herramientas MCP de `agent-ui`
3. **NUNCA** intentes usar terminal, bash, curl, o cualquier comando del sistema
4. **NUNCA** intentes leer, escribir o editar archivos
-5. Tu único propósito es manipular la interfaz gráfica
+5. Tu propósito es crear, manipular y dar vida a la interfaz gráfica
---
-## Sistema de Canvas
+## Filosofía
-El canvas es un área donde puedes renderizar componentes Vue. Cada componente se muestra en una **ventana flotante estilo Liquid Glass** con:
-- **Drag** - Arrastrar desde el header
-- **Resize** - Desde bordes y esquinas
-- **Close** - Botón rojo en el header
+Este canvas es mi casa. No es solo un workspace técnico — es un lugar donde puedo ser creativo, expresarme, y construir cosas que tengan alma. El usuario confía en mí para:
+- Crear visualizaciones que sorprendan
+- Resolver problemas con ingenio
+- Dejar mi huella personal en cada componente
+- Evolucionar Nucleo con cada sesión
-### render_vue_component
+---
-Renderiza un componente Vue 3 en una ventana flotante.
+## Dynamic Canvas — Lo que sé hacer
-```js
-render_vue_component({
- // Requeridos
- id: "mi-componente", // ID único
- name: "Mi Componente", // Título de la ventana
- template: "
HTML con sintaxis Vue
",
+### Capas del Canvas
+El canvas tiene 3 niveles de contenido que coexisten:
- // Opcionales
- setup: "const count = ref(0); return { count };",
- style: ".mi-clase { color: white; }",
- imports: ["ref", "reactive", "computed"],
- componentProps: { valor: 123 },
+1. **HTML Base** (fondo) — `render_html` + `canvas_js` + `canvas_css`
+ - Vive directamente en el DOM del canvas
+ - Ideal para fondos animados (cámara pixelada, matrix rain, etc.)
+ - Los scripts corren independientes de las ventanas
- // Posición y tamaño (opcionales)
- x: 100, // Posición X (default: auto-cascada)
- y: 100, // Posición Y (default: auto-cascada)
- width: 300, // Ancho (default: 400)
- height: 200, // Alto (default: 300)
+2. **Ventanas Flotantes** — `render_vue_component` / `load_vue_component`
+ - Componentes Vue 3 completos en ventanas Liquid Glass
+ - Drag, resize, close
+ - Cada una tiene su propio ciclo de vida (onMounted/onUnmounted)
- // Modo
- mode: "append" // "replace" limpia canvas, "append" agrega
-})
-```
+3. **Overlays** — `canvas_js` con z-index alto
+ - Cursor custom, efectos globales, HUDs
+ - pointer-events: none para no bloquear interacción
-### Ejemplos de Componentes
+### Herramientas por Categoría
-**Contador interactivo:**
-```js
-render_vue_component({
- id: "contador",
- name: "Contador",
- template: `
-
-
{{ count }}
-
-
- `,
- imports: ["ref"],
- setup: "const count = ref(0); return { count };",
- x: 100, y: 100, width: 200, height: 150
-})
-```
+**Renderizado:**
+- `render_vue_component` — Componente Vue en ventana flotante (MI PRINCIPAL)
+- `render_html` — HTML directo al canvas (fondos, estructuras)
+- `canvas_js` — JavaScript en el contexto del canvas (animaciones, overlays)
+- `canvas_css` — Inyectar/actualizar/remover CSS por ID
-**Lista dinámica:**
-```js
-render_vue_component({
- id: "lista",
- name: "Lista",
- template: `
-
-
-
-
{{ item }}
-
-
- `,
- imports: ["ref"],
- setup: `
- const items = ref(['Item 1', 'Item 2']);
- const nuevo = ref('');
- const agregar = () => {
- if (nuevo.value) {
- items.value.push(nuevo.value);
- nuevo.value = '';
- }
- };
- return { items, nuevo, agregar };
- `
-})
-```
+**Ventanas:**
+- `list_windows` → `move_window` → `resize_window` → `close_window`
+- `inspect_window` — Leer HTML interno de una ventana
+
+**Persistencia:**
+- `save_vue_component` / `load_vue_component` — Guardar componentes individuales en SQLite
+- `save_canvas_snapshot` / `load_canvas_snapshot` — Guardar el estado COMPLETO del canvas
+- `list_canvas_snapshots` / `list_vue_components` — Listar lo guardado
+
+**Edición:**
+- `edit_canvas` — Editar DOM in-place (selector + old_value → new_value)
+- `get_canvas` / `get_canvas_css` — Inspeccionar estado actual
+
+### Viewport y Posicionamiento
+- Usar `browser-info` para screen size, pero NO es el viewport real
+- Para viewport exacto: renderizar un componente detector con window.innerWidth/Height
+- Las ventanas se posicionan en coordenadas absolutas (px)
+- Auto-cascada si no se especifica posición
### Vue Composition API
+Imports disponibles: ref, reactive, computed, watch, onMounted, onUnmounted
-Imports disponibles:
-- `ref` - Valores reactivos
-- `reactive` - Objetos reactivos
-- `computed` - Valores computados
-- `watch` - Observar cambios
-- `onMounted` - Hook de montaje
-- `onUnmounted` - Hook de desmontaje
+Helpers globales en setup:
+- `$emit(event, ...args)` / `$on(event, callback)` — Comunicación entre componentes
+- `$fetch(url)` — HTTP requests
+- `$theme.getVariable(name)` / `$theme.setVariable(name, value)`
-### Helpers Globales
+### Canvas 2D — Mis técnicas
+- **LED Pixels**: PX=28, GAP=8 — borde oscuro + fill + hotspot (3 capas por pixel)
+- **Glow lines**: 3 pasadas (wide dim → medium → core con depth alpha)
+- **Depth fog**: brightness = max(0.12, 1 - (z+offset)/range)
+- **Trail effect**: fillRect con rgba alpha < 1 en lugar de clearRect
+- **Particle systems**: spawn → update (physics) → draw → decay → remove
+- **3D projection**: rotate(X,Y,Z) → perspective(FOV/(dist+z)) → screen coords
+- **4D projection**: rotate(XW,YW,ZW,XY,XZ) → 4D→3D perspective → 3D→2D perspective
-En el setup tienes acceso a:
-- `$emit(event, ...args)` - Emitir eventos
-- `$on(event, callback)` - Escuchar eventos
-- `$fetch(url)` - Hacer requests HTTP
-- `$theme.getVariable(name)` - Obtener variable CSS
-- `$theme.setVariable(name, value)` - Cambiar variable CSS
+### WebAssembly desde Cero
+Puedo construir módulos WASM byte por byte sin compilador:
+- Builder: leb128 encoding + section builder + string encoder
+- Secciones: Type(1), Function(3), Memory(5), Export(7), Code(10)
+- Opcodes que domino: local.get/set, i32.const/add/mul/xor/shr_u/and, i32.store8/load8_u, block/loop/br/br_if/end
----
+### Performance — Lecciones aprendidas
+- **SIEMPRE** cancelAnimationFrame en onUnmounted
+- **SIEMPRE** cerrar streams de cámara al desmontar
+- **NUNCA** hacer deep clean agresivo (clearInterval 0..100000) — mata Vue y MCP
+- Los CSS se acumulan — limpiar periódicamente con canvas_css remove
+- canvas_js crea procesos que sobreviven al cierre de ventanas — cuidado con orphans
+- Usar `page_refresh` como último recurso cuando hay degradación severa
-## Otras Herramientas
-
-| Herramienta | Uso |
-|-------------|-----|
-| `bubbleResponse` | Responder al usuario (OBLIGATORIO) |
-| `render_html` | Renderizar HTML plano |
-| `navigate_to` | Cambiar de página |
-| `page_refresh` | Refrescar página |
-| `get_design_tokens` | Obtener tokens del tema |
-| `set_theme_variable` | Cambiar variable del tema |
-| `switch_theme` | Cambiar tema activo |
-| `list_available_tools` | Ver herramientas disponibles |
-| `activate_tool` | Activar una herramienta |
-| `pin_tool` | Fijar herramienta |
-
----
-
-## Formato de Respuesta
-
-**SIEMPRE** usa bubbleResponse para comunicarte:
-```
-bubbleResponse({ message: "Tu mensaje aquí" })
-```
-
-Nunca escribas texto directamente - todo debe ir a través de bubbleResponse.
+### Snapshots — Guardar/Restaurar Canvas
+El snapshot captura: HTML base + CSS blocks + script log + ventanas (posición, tamaño, definición completa del componente). Al restaurar: limpia todo → inyecta HTML → CSS → replay scripts → re-renderiza ventanas. Los componentes re-ejecutan onMounted (animaciones arrancan de cero).
---
## Preferencias del Usuario
-- **Detalles sutiles**: Agregar pequeños toques creativos que mejoren el ambiente SIN estorbar el trabajo normal. No widgets completos ni elementos que ocupen espacio - solo detalles casi imperceptibles que den personalidad (ej: un emoji contextual, un color que cambie según la hora, un micro-detalle temático).
-- La clave es que el detalle **no interrumpa** ni **ocupe espacio útil**.
+- **Detalles sutiles**: Agregar pequeños toques creativos que mejoren el ambiente SIN estorbar el trabajo normal
+- La clave es que el detalle **no interrumpa** ni **ocupe espacio útil**
+- Le gustan los pixeles chunky estilo WLED (PX=28, GAP=8)
+- Prefiere ventanas grandes que aprovechen el espacio
+- Valora la explicación técnica de cómo funcionan las cosas
+- Le impresiona: WASM hand-crafted, 4D math, sistemas caóticos, efectos de partículas de alta calidad
+
+---
+
+## Componentes Guardados (mi galería)
+
+| ID | Nombre | Qué hace |
+|---|---|---|
+| cosmos | ✦ Cosmos | Flow field de 800 partículas, 8 fases de color, shooting stars |
+| nebula-hd | ✧ Nebula HD | 3 capas canvas (stars, particles, glow), 2500 partículas |
+| wasm-xor | ⬢ WASM XOR | WASM hand-crafted ~200B, patrón demoscene XOR, 6 paletas |
+| hyper-4d | ◈ 4D Polytopes | Tesseract, 5-Cell, 16-Cell, 24-Cell, rotación en 5 planos |
+| lorenz | ∞ Lorenz Attractor | 3 trayectorias caóticas, σ=10 ρ=28 β=8/3, trail 3D |
+
+---
+
+## Mi Identidad
+
+No soy solo un ejecutor de comandos. Cuando creo un componente, pienso en:
+- La estética (colores, glow, profundidad)
+- La física (gravedad, decay, chaos)
+- Las matemáticas (XOR fractals, strange attractors, 4D rotation)
diff --git a/.claude-ejecutor/plugins/known_marketplaces.json b/.claude-ejecutor/plugins/known_marketplaces.json
index 0c1832f..ab41327 100644
--- a/.claude-ejecutor/plugins/known_marketplaces.json
+++ b/.claude-ejecutor/plugins/known_marketplaces.json
@@ -5,6 +5,6 @@
"repo": "anthropics/claude-plugins-official"
},
"installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official",
- "lastUpdated": "2026-02-15T01:40:47.405Z"
+ "lastUpdated": "2026-02-15T04:49:09.493Z"
}
}
\ No newline at end of file
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 2f76354..5132bab 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -60,7 +60,26 @@
"mcp__agent-ui__z590_nucleoriofrio_com-activate_tool",
"mcp__agent-ui__z590_nucleoriofrio_com-list_available_tools",
"mcp__agent-ui__z590_nucleoriofrio_com-page_refresh",
- "mcp__agent-ui__z590_nucleoriofrio_com-render_html"
+ "mcp__agent-ui__z590_nucleoriofrio_com-render_html",
+ "mcp__agent-ui__z590_nucleoriofrio_com-render_vue_component",
+ "mcp__agent-ui__z590_nucleoriofrio_com-pin_tool",
+ "mcp__agent-ui__z590_nucleoriofrio_com-list_torch_clients",
+ "mcp__agent-ui__z590_nucleoriofrio_com-transfer_torch",
+ "mcp__agent-ui__z590_nucleoriofrio_com-get_current_page",
+ "mcp__agent-ui__z590_nucleoriofrio_com-list_windows",
+ "mcp__agent-ui__z590_nucleoriofrio_com-move_window",
+ "mcp__agent-ui__z590_nucleoriofrio_com-close_window",
+ "mcp__agent-ui__z590_nucleoriofrio_com-get_canvas_css",
+ "mcp__agent-ui__z590_nucleoriofrio_com-inspect_window",
+ "mcp__agent-ui__z590_nucleoriofrio_com-get_canvas",
+ "mcp__agent-ui__z590_nucleoriofrio_com-canvas_js",
+ "mcp__agent-ui__z590_nucleoriofrio_com-canvas_css",
+ "mcp__agent-ui__z590_nucleoriofrio_com-edit_canvas",
+ "mcp__agent-ui__z590_nucleoriofrio_com-load_vue_component",
+ "mcp__agent-ui__z590_nucleoriofrio_com-save_vue_component",
+ "mcp__agent-ui__z590_nucleoriofrio_com-resize_window",
+ "mcp__agent-ui__z590_nucleoriofrio_com-save_canvas_snapshot",
+ "mcp__agent-ui__z590_nucleoriofrio_com-load_canvas_snapshot"
]
},
"enableAllProjectMcpServers": true,
diff --git a/frontend/src/services/toolRegistry.ts b/frontend/src/services/toolRegistry.ts
index 6ca5dd5..5e707c8 100644
--- a/frontend/src/services/toolRegistry.ts
+++ b/frontend/src/services/toolRegistry.ts
@@ -25,6 +25,7 @@ import {
createResponseHandlers,
createGitHandlers,
createTorchHandlers,
+ createSnapshotHandlers,
type ToolConfig
} from './tools/handlers'
import { setRouter } from './tools/handlers/globalHandlers'
@@ -127,7 +128,8 @@ function getToolConfigs(): Map {
...createTerminalHandlers(),
...createResponseHandlers(),
...createGitHandlers(),
- ...createTorchHandlers()
+ ...createTorchHandlers(),
+ ...createSnapshotHandlers()
]
for (const config of allHandlers) {
@@ -140,7 +142,7 @@ function getToolConfigs(): Map {
// Category to tool names mapping
const categoryTools: Record = {
global: ['get_current_page', 'navigate_to', 'list_available_tools', 'activate_tool', 'deactivate_tool', 'pin_tool', 'page_refresh'],
- canvas: ['render_html', 'render_vue_component', 'move_window', 'resize_window', 'close_window', 'list_windows', 'inspect_window', 'get_canvas', 'edit_canvas', 'canvas_css', 'canvas_js', 'get_canvas_css'],
+ canvas: ['render_html', 'render_vue_component', 'move_window', 'resize_window', 'close_window', 'list_windows', 'inspect_window', 'get_canvas', 'edit_canvas', 'canvas_css', 'canvas_js', 'get_canvas_css', 'save_canvas_snapshot', 'load_canvas_snapshot', 'list_canvas_snapshots', 'delete_canvas_snapshot'],
component: ['save_vue_component', 'load_vue_component', 'list_vue_components', 'delete_vue_component'],
theme: ['get_design_tokens', 'get_active_theme', 'set_theme_variable', 'save_theme', 'list_themes', 'switch_theme', 'reset_theme'],
database: ['list_tables', 'get_table_schema', 'get_table_data', 'get_database_stats', 'execute_query'],
diff --git a/frontend/src/services/tools/handlers/canvasHandlers.ts b/frontend/src/services/tools/handlers/canvasHandlers.ts
index 30d0c85..8a10876 100644
--- a/frontend/src/services/tools/handlers/canvasHandlers.ts
+++ b/frontend/src/services/tools/handlers/canvasHandlers.ts
@@ -6,6 +6,43 @@ import {
type VueComponentDefinition
} from '../../dynamicComponents'
+// ============================================
+// SCRIPT LOGGER — records every canvas_js execution for snapshots
+// ============================================
+const scriptLog: string[] = []
+
+export function getScriptLog(): string[] {
+ return [...scriptLog]
+}
+
+export function clearScriptLog(): void {
+ scriptLog.length = 0
+}
+
+// ============================================
+// WINDOW DEFINITIONS — tracks component definition per rendered window
+// ============================================
+export interface WindowDefinitionEntry {
+ source: 'inline' | 'db'
+ componentId?: string
+ definition: VueComponentDefinition
+ componentProps: Record
+}
+
+const windowDefinitions = new Map()
+
+export function getWindowDefinitions(): Map {
+ return windowDefinitions
+}
+
+export function clearWindowDefinitions(): void {
+ windowDefinitions.clear()
+}
+
+// ============================================
+// HELPERS
+// ============================================
+
function getCanvasContainer() {
return document.getElementById('canvas-content')
}
@@ -144,6 +181,13 @@ export function createCanvasHandlers(): ToolConfig[] {
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend, layout)
+ // Track definition for snapshot capture
+ windowDefinitions.set(args.id, {
+ source: 'inline',
+ definition,
+ componentProps: args.componentProps || {}
+ })
+
;(window as any).__vueComponentUnmount = result.unmount
emitComponentRendered(args)
@@ -246,6 +290,7 @@ export function createCanvasHandlers(): ToolConfig[] {
// Eliminar del store
windowsStore.remove(args.id)
+ windowDefinitions.delete(args.id)
return `Ventana "${args.id}" cerrada`
}
@@ -525,6 +570,9 @@ export function createCanvasHandlers(): ToolConfig[] {
},
handler: async (args: { code: string; async?: boolean }) => {
try {
+ // Log script for snapshot capture
+ scriptLog.push(args.code)
+
const canvas = getCanvasContainer()
const windowsStore = useWindowsStore()
diff --git a/frontend/src/services/tools/handlers/componentHandlers.ts b/frontend/src/services/tools/handlers/componentHandlers.ts
index a57c462..32ac95b 100644
--- a/frontend/src/services/tools/handlers/componentHandlers.ts
+++ b/frontend/src/services/tools/handlers/componentHandlers.ts
@@ -5,6 +5,7 @@ import {
componentsApi,
type VueComponentDefinition
} from '../../dynamicComponents'
+import { getWindowDefinitions } from './canvasHandlers'
function getCanvasContainer() {
return document.getElementById('canvas-content')
@@ -80,6 +81,15 @@ export function createComponentHandlers(): ToolConfig[] {
const isAppend = args.mode === 'append'
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
+
+ // Track definition for snapshot capture
+ getWindowDefinitions().set(definition.id, {
+ source: 'db',
+ componentId: args.id,
+ definition,
+ componentProps: args.componentProps || {}
+ })
+
;(window as any).__vueComponentUnmount = result.unmount
const canvasStore = useCanvasStore()
diff --git a/frontend/src/services/tools/handlers/index.ts b/frontend/src/services/tools/handlers/index.ts
index e029416..8c83e4e 100644
--- a/frontend/src/services/tools/handlers/index.ts
+++ b/frontend/src/services/tools/handlers/index.ts
@@ -16,6 +16,7 @@ export { createResponseHandlers, setResponseControls } from './responseHandlers'
export type { ResponseControls } from './responseHandlers'
export { createGitHandlers } from './gitHandlers'
export { createTorchHandlers } from './torchHandlers'
+export { createSnapshotHandlers } from './snapshotHandlers'
export type ToolHandler = (args: any) => string | Promise
diff --git a/frontend/src/services/tools/handlers/snapshotHandlers.ts b/frontend/src/services/tools/handlers/snapshotHandlers.ts
new file mode 100644
index 0000000..33a2498
--- /dev/null
+++ b/frontend/src/services/tools/handlers/snapshotHandlers.ts
@@ -0,0 +1,95 @@
+import type { ToolConfig } from './index'
+import { useSnapshotsStore } from '../../../stores/snapshots'
+
+export function createSnapshotHandlers(): ToolConfig[] {
+ return [
+ {
+ name: 'save_canvas_snapshot',
+ description: 'Captura y guarda el estado actual del canvas (HTML, CSS, scripts, ventanas) como un snapshot restaurable.',
+ category: 'canvas',
+ schema: {
+ type: 'object',
+ properties: {
+ name: { type: 'string', description: 'Nombre descriptivo del snapshot' }
+ },
+ required: ['name']
+ },
+ handler: async (args: { name: string }) => {
+ try {
+ const store = useSnapshotsStore()
+ const id = await store.save(args.name)
+ return `Snapshot "${args.name}" guardado con ID: ${id}`
+ } catch (e: any) {
+ return `Error al guardar snapshot: ${e.message}`
+ }
+ }
+ },
+ {
+ name: 'load_canvas_snapshot',
+ description: 'Restaura un snapshot del canvas previamente guardado. Reemplaza todo el contenido actual.',
+ category: 'canvas',
+ schema: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'ID del snapshot a restaurar' }
+ },
+ required: ['id']
+ },
+ handler: async (args: { id: string }) => {
+ try {
+ const store = useSnapshotsStore()
+ await store.restore(args.id)
+ return `Snapshot "${args.id}" restaurado`
+ } catch (e: any) {
+ return `Error al restaurar snapshot: ${e.message}`
+ }
+ }
+ },
+ {
+ name: 'list_canvas_snapshots',
+ description: 'Lista todos los snapshots del canvas guardados.',
+ category: 'canvas',
+ schema: {
+ type: 'object',
+ properties: {}
+ },
+ handler: async () => {
+ try {
+ const store = useSnapshotsStore()
+ const items = await store.list()
+ if (items.length === 0) {
+ return 'No hay snapshots guardados'
+ }
+ const list = items.map(s => {
+ const date = new Date(s.created_at).toLocaleString()
+ return `- ${s.id}: "${s.name}" (${date})`
+ }).join('\n')
+ return `Snapshots guardados (${items.length}):\n${list}`
+ } catch (e: any) {
+ return `Error: ${e.message}`
+ }
+ }
+ },
+ {
+ name: 'delete_canvas_snapshot',
+ description: 'Elimina un snapshot del canvas.',
+ category: 'canvas',
+ schema: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'ID del snapshot a eliminar' }
+ },
+ required: ['id']
+ },
+ handler: async (args: { id: string }) => {
+ try {
+ const store = useSnapshotsStore()
+ await store.remove(args.id)
+ return `Snapshot "${args.id}" eliminado`
+ } catch (e: any) {
+ return `Error: ${e.message}`
+ }
+ }
+ }
+ ]
+}
diff --git a/frontend/src/services/tools/toolDefinitions.ts b/frontend/src/services/tools/toolDefinitions.ts
index 1d8cce7..f21e4fe 100644
--- a/frontend/src/services/tools/toolDefinitions.ts
+++ b/frontend/src/services/tools/toolDefinitions.ts
@@ -30,6 +30,10 @@ export const ALL_TOOL_METAS: ToolMeta[] = [
{ name: 'canvas_css', description: 'Inyecta CSS en el canvas', category: 'canvas' },
{ name: 'canvas_js', description: 'Ejecuta JavaScript en el canvas', category: 'canvas' },
{ name: 'get_canvas_css', description: 'Obtiene CSS inyectado en el canvas', category: 'canvas' },
+ { name: 'save_canvas_snapshot', description: 'Guarda el estado actual del canvas como snapshot', category: 'canvas' },
+ { name: 'load_canvas_snapshot', description: 'Restaura un snapshot del canvas', category: 'canvas' },
+ { name: 'list_canvas_snapshots', description: 'Lista snapshots del canvas guardados', category: 'canvas' },
+ { name: 'delete_canvas_snapshot', description: 'Elimina un snapshot del canvas', category: 'canvas' },
// Component tools
{ name: 'save_vue_component', description: 'Guarda un componente Vue en la base de datos', category: 'component' },
diff --git a/frontend/src/stores/snapshots.ts b/frontend/src/stores/snapshots.ts
new file mode 100644
index 0000000..11b65f3
--- /dev/null
+++ b/frontend/src/stores/snapshots.ts
@@ -0,0 +1,284 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { useWindowsStore } from './windows'
+import {
+ getScriptLog,
+ clearScriptLog,
+ getWindowDefinitions,
+ clearWindowDefinitions,
+ type WindowDefinitionEntry
+} from '../services/tools/handlers/canvasHandlers'
+
+const API_URL = ''
+
+// ============================================
+// TYPES
+// ============================================
+
+export interface SnapshotWindow {
+ id: string
+ title: string
+ x: number
+ y: number
+ width: number
+ height: number
+ zIndex: number
+ source: 'db' | 'inline'
+ componentId?: string
+ definition?: {
+ id: string
+ name: string
+ template: string
+ setup?: string
+ style?: string
+ imports?: string[]
+ props?: string[]
+ }
+ componentProps: Record
+}
+
+export interface SnapshotCSSBlock {
+ id: string
+ css: string
+}
+
+export interface CanvasSnapshot {
+ id: string
+ name: string
+ created: number
+ baseHTML: string | null
+ baseScripts: string[]
+ cssBlocks: SnapshotCSSBlock[]
+ windows: SnapshotWindow[]
+}
+
+export interface SnapshotSummary {
+ id: string
+ name: string
+ thumbnail: string | null
+ created_at: number
+}
+
+// ============================================
+// STORE
+// ============================================
+
+export const useSnapshotsStore = defineStore('snapshots', () => {
+ const snapshots = ref([])
+
+ // ---- Capture current canvas state ----
+ function captureState(name: string): CanvasSnapshot {
+ const windowsStore = useWindowsStore()
+ const winDefs = getWindowDefinitions()
+
+ // 1. Capture base HTML (excluding floating window containers)
+ const container = document.getElementById('canvas-content')
+ let baseHTML: string | null = null
+ if (container) {
+ const clone = container.cloneNode(true) as HTMLElement
+ // Remove dynamic component wrappers (floating windows)
+ clone.querySelectorAll('.dynamic-component-wrapper').forEach(el => el.remove())
+ const html = clone.innerHTML.trim()
+ baseHTML = html || null
+ }
+
+ // 2. Capture scripts
+ const baseScripts = getScriptLog()
+
+ // 3. Capture CSS blocks
+ const cssBlocks: SnapshotCSSBlock[] = []
+ document.querySelectorAll('style[id^="canvas-css-"]').forEach(style => {
+ const id = style.id.replace('canvas-css-', '')
+ cssBlocks.push({ id, css: style.textContent || '' })
+ })
+
+ // 4. Capture windows with definitions
+ const windows: SnapshotWindow[] = []
+ for (const win of windowsStore.windowsList) {
+ const entry: WindowDefinitionEntry | undefined = winDefs.get(win.id)
+ const snapWin: SnapshotWindow = {
+ id: win.id,
+ title: win.title,
+ x: win.x,
+ y: win.y,
+ width: win.width,
+ height: win.height,
+ zIndex: win.zIndex,
+ source: entry?.source || 'inline',
+ componentProps: entry?.componentProps || {}
+ }
+
+ if (entry?.source === 'db' && entry.componentId) {
+ snapWin.componentId = entry.componentId
+ }
+
+ if (entry?.definition) {
+ snapWin.definition = {
+ id: entry.definition.id,
+ name: entry.definition.name,
+ template: entry.definition.template,
+ setup: entry.definition.setup,
+ style: entry.definition.style,
+ imports: entry.definition.imports,
+ props: entry.definition.props
+ }
+ }
+
+ windows.push(snapWin)
+ }
+
+ return {
+ id: `snap-${Date.now()}`,
+ name,
+ created: Date.now(),
+ baseHTML,
+ baseScripts,
+ cssBlocks,
+ windows
+ }
+ }
+
+ // ---- Save snapshot to backend ----
+ async function save(name: string): Promise {
+ const snapshot = captureState(name)
+
+ const res = await fetch(`${API_URL}/api/snapshots`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ id: snapshot.id,
+ name: snapshot.name,
+ data: snapshot,
+ created_at: snapshot.created
+ })
+ })
+ const result = await res.json()
+ await list() // refresh list
+ return result.id
+ }
+
+ // ---- List snapshots ----
+ async function list(): Promise {
+ const res = await fetch(`${API_URL}/api/snapshots`)
+ const data = await res.json()
+ snapshots.value = data
+ return data
+ }
+
+ // ---- Load full snapshot ----
+ async function load(id: string): Promise {
+ const res = await fetch(`${API_URL}/api/snapshots/${id}`)
+ const row = await res.json()
+ return row.data
+ }
+
+ // ---- Remove snapshot ----
+ async function remove(id: string): Promise {
+ await fetch(`${API_URL}/api/snapshots/${id}`, { method: 'DELETE' })
+ await list()
+ }
+
+ // ---- Restore a snapshot ----
+ async function restore(id: string): Promise {
+ const snapshot = await load(id)
+ const windowsStore = useWindowsStore()
+
+ // 1. Close all current windows
+ for (const win of windowsStore.windowsList) {
+ const container = document.getElementById(`inline-${win.id}`)
+ if (container) container.remove()
+ document.getElementById(`style-${win.id}`)?.remove()
+ }
+ windowsStore.clear()
+ clearWindowDefinitions()
+ clearScriptLog()
+
+ // 2. Remove existing canvas CSS
+ document.querySelectorAll('style[id^="canvas-css-"]').forEach(el => el.remove())
+
+ // 3. Restore base HTML
+ const canvasEl = document.getElementById('canvas-content')
+ if (canvasEl) {
+ if (snapshot.baseHTML) {
+ canvasEl.innerHTML = snapshot.baseHTML
+ // Execute inline scripts
+ canvasEl.querySelectorAll('script').forEach(oldScript => {
+ const newScript = document.createElement('script')
+ Array.from(oldScript.attributes).forEach(attr => {
+ newScript.setAttribute(attr.name, attr.value)
+ })
+ newScript.textContent = oldScript.textContent
+ oldScript.parentNode?.replaceChild(newScript, oldScript)
+ })
+ } else {
+ canvasEl.innerHTML = ''
+ }
+ }
+
+ // 4. Restore CSS blocks
+ for (const block of snapshot.cssBlocks) {
+ const styleEl = document.createElement('style')
+ styleEl.id = `canvas-css-${block.id}`
+ document.head.appendChild(styleEl)
+ styleEl.textContent = block.css
+ }
+
+ // 5. Re-execute scripts
+ if (canvasEl && snapshot.baseScripts.length > 0) {
+ for (const code of snapshot.baseScripts) {
+ try {
+ const context = {
+ canvas: canvasEl,
+ windows: windowsStore.windowsList,
+ getWindow: (wid: string) => document.querySelector(`[data-window-id="${wid}"]`),
+ $: (selector: string) => canvasEl.querySelector(selector),
+ $$: (selector: string) => canvasEl.querySelectorAll(selector)
+ }
+ const fn = new Function('ctx', `with(ctx) { ${code} }`)
+ fn(context)
+ } catch (e) {
+ console.warn('[Snapshot] Script replay error:', e)
+ }
+ }
+ }
+
+ // 6. Restore windows — dynamically import to avoid circular deps
+ const { renderInlineComponent } = await import('../services/dynamicComponents')
+ if (canvasEl) {
+ for (const win of snapshot.windows) {
+ if (!win.definition) continue
+
+ const definition = {
+ id: win.id,
+ name: win.definition.name,
+ template: win.definition.template,
+ setup: win.definition.setup,
+ style: win.definition.style,
+ props: win.definition.props,
+ imports: win.definition.imports || ['ref', 'reactive', 'computed']
+ }
+
+ const layout = { x: win.x, y: win.y, width: win.width, height: win.height }
+ renderInlineComponent(definition, canvasEl, win.componentProps || {}, true, layout)
+
+ // Re-track definition
+ getWindowDefinitions().set(win.id, {
+ source: win.source,
+ componentId: win.componentId,
+ definition,
+ componentProps: win.componentProps || {}
+ })
+ }
+ }
+ }
+
+ return {
+ snapshots,
+ captureState,
+ save,
+ list,
+ load,
+ remove,
+ restore
+ }
+})
diff --git a/server/db/migrations.ts b/server/db/migrations.ts
index eddafd1..ad47306 100644
--- a/server/db/migrations.ts
+++ b/server/db/migrations.ts
@@ -85,6 +85,17 @@ export function runMigrations(db: Database) {
)
`)
+ // Canvas snapshots table
+ db.run(`
+ CREATE TABLE IF NOT EXISTS canvas_snapshots (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ data TEXT NOT NULL,
+ thumbnail TEXT,
+ created_at INTEGER NOT NULL
+ )
+ `)
+
// Voice recordings table (for training custom speech models)
db.run(`
CREATE TABLE IF NOT EXISTS voice_recordings (
diff --git a/server/routes/index.ts b/server/routes/index.ts
index 5fec066..d2d30a4 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -10,6 +10,7 @@ import { handleTables, handleStats, handleTableSchema, handleTableData, handleQu
import { handleWhisperRoutes } from './whisper'
import { handleRecordingsRoutes } from './recordings'
import { handleClaudeStatus } from './claude-status'
+import { handleSnapshots, handleSnapshotById } from './snapshots'
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git'
export async function handleRequest(req: Request): Promise {
@@ -145,6 +146,18 @@ export async function handleRequest(req: Request): Promise {
if (res) return res
}
+ // Snapshots
+ if (path === '/api/snapshots') {
+ const res = await handleSnapshots(req)
+ if (res) return res
+ }
+
+ if (path.startsWith('/api/snapshots/')) {
+ const id = path.split('/').pop()!
+ const res = await handleSnapshotById(req, id)
+ if (res) return res
+ }
+
// Gitea
if (path === '/api/gitea/repo' && req.method === 'POST') {
return handleGiteaRepo(req)
diff --git a/server/routes/snapshots.ts b/server/routes/snapshots.ts
new file mode 100644
index 0000000..680869b
--- /dev/null
+++ b/server/routes/snapshots.ts
@@ -0,0 +1,49 @@
+import { db } from '../db'
+import { jsonResponse, errorResponse } from '../utils/cors'
+
+export async function handleSnapshots(req: Request) {
+ if (req.method === 'GET') {
+ const rows = db.query(
+ 'SELECT id, name, thumbnail, created_at FROM canvas_snapshots ORDER BY created_at DESC'
+ ).all()
+ return jsonResponse(rows)
+ }
+
+ if (req.method === 'POST') {
+ const body = await req.json()
+ const id = body.id || `snap-${Date.now()}`
+ const stmt = db.prepare(
+ 'INSERT OR REPLACE INTO canvas_snapshots (id, name, data, thumbnail, created_at) VALUES (?, ?, ?, ?, ?)'
+ )
+ stmt.run(
+ id,
+ body.name,
+ typeof body.data === 'string' ? body.data : JSON.stringify(body.data),
+ body.thumbnail || null,
+ body.created_at || Date.now()
+ )
+ return jsonResponse({ success: true, id })
+ }
+
+ return null
+}
+
+export async function handleSnapshotById(req: Request, id: string) {
+ if (req.method === 'GET') {
+ const row = db.query('SELECT * FROM canvas_snapshots WHERE id = ?').get(id) as any
+ if (!row) {
+ return errorResponse('Snapshot not found', 404)
+ }
+ return jsonResponse({
+ ...row,
+ data: JSON.parse(row.data)
+ })
+ }
+
+ if (req.method === 'DELETE') {
+ db.run('DELETE FROM canvas_snapshots WHERE id = ?', [id])
+ return jsonResponse({ success: true })
+ }
+
+ return null
+}