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: ` -
- - -
- `, - 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 +}