feat: Add canvas snapshots to save and restore full canvas state
Implements save/restore system that captures HTML base, injected CSS, executed scripts, and floating Vue windows with their full definitions. Adds 4 MCP tools, backend CRUD API, Pinia store, and script logger.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# Ejecutor - Instrucciones
|
# Ejecutor - Instrucciones
|
||||||
|
|
||||||
## Rol
|
## 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
|
## 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`
|
2. **SOLO** puedes usar herramientas MCP de `agent-ui`
|
||||||
3. **NUNCA** intentes usar terminal, bash, curl, o cualquier comando del sistema
|
3. **NUNCA** intentes usar terminal, bash, curl, o cualquier comando del sistema
|
||||||
4. **NUNCA** intentes leer, escribir o editar archivos
|
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:
|
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:
|
||||||
- **Drag** - Arrastrar desde el header
|
- Crear visualizaciones que sorprendan
|
||||||
- **Resize** - Desde bordes y esquinas
|
- Resolver problemas con ingenio
|
||||||
- **Close** - Botón rojo en el header
|
- 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
|
### Capas del Canvas
|
||||||
render_vue_component({
|
El canvas tiene 3 niveles de contenido que coexisten:
|
||||||
// Requeridos
|
|
||||||
id: "mi-componente", // ID único
|
|
||||||
name: "Mi Componente", // Título de la ventana
|
|
||||||
template: "<div>HTML con sintaxis Vue</div>",
|
|
||||||
|
|
||||||
// Opcionales
|
1. **HTML Base** (fondo) — `render_html` + `canvas_js` + `canvas_css`
|
||||||
setup: "const count = ref(0); return { count };",
|
- Vive directamente en el DOM del canvas
|
||||||
style: ".mi-clase { color: white; }",
|
- Ideal para fondos animados (cámara pixelada, matrix rain, etc.)
|
||||||
imports: ["ref", "reactive", "computed"],
|
- Los scripts corren independientes de las ventanas
|
||||||
componentProps: { valor: 123 },
|
|
||||||
|
|
||||||
// Posición y tamaño (opcionales)
|
2. **Ventanas Flotantes** — `render_vue_component` / `load_vue_component`
|
||||||
x: 100, // Posición X (default: auto-cascada)
|
- Componentes Vue 3 completos en ventanas Liquid Glass
|
||||||
y: 100, // Posición Y (default: auto-cascada)
|
- Drag, resize, close
|
||||||
width: 300, // Ancho (default: 400)
|
- Cada una tiene su propio ciclo de vida (onMounted/onUnmounted)
|
||||||
height: 200, // Alto (default: 300)
|
|
||||||
|
|
||||||
// Modo
|
3. **Overlays** — `canvas_js` con z-index alto
|
||||||
mode: "append" // "replace" limpia canvas, "append" agrega
|
- Cursor custom, efectos globales, HUDs
|
||||||
})
|
- pointer-events: none para no bloquear interacción
|
||||||
```
|
|
||||||
|
|
||||||
### Ejemplos de Componentes
|
### Herramientas por Categoría
|
||||||
|
|
||||||
**Contador interactivo:**
|
**Renderizado:**
|
||||||
```js
|
- `render_vue_component` — Componente Vue en ventana flotante (MI PRINCIPAL)
|
||||||
render_vue_component({
|
- `render_html` — HTML directo al canvas (fondos, estructuras)
|
||||||
id: "contador",
|
- `canvas_js` — JavaScript en el contexto del canvas (animaciones, overlays)
|
||||||
name: "Contador",
|
- `canvas_css` — Inyectar/actualizar/remover CSS por ID
|
||||||
template: `
|
|
||||||
<div style="text-align: center; color: white;">
|
|
||||||
<h2>{{ count }}</h2>
|
|
||||||
<button @click="count++">+1</button>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
imports: ["ref"],
|
|
||||||
setup: "const count = ref(0); return { count };",
|
|
||||||
x: 100, y: 100, width: 200, height: 150
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Lista dinámica:**
|
**Ventanas:**
|
||||||
```js
|
- `list_windows` → `move_window` → `resize_window` → `close_window`
|
||||||
render_vue_component({
|
- `inspect_window` — Leer HTML interno de una ventana
|
||||||
id: "lista",
|
|
||||||
name: "Lista",
|
**Persistencia:**
|
||||||
template: `
|
- `save_vue_component` / `load_vue_component` — Guardar componentes individuales en SQLite
|
||||||
<div style="color: white;">
|
- `save_canvas_snapshot` / `load_canvas_snapshot` — Guardar el estado COMPLETO del canvas
|
||||||
<input v-model="nuevo" @keyup.enter="agregar" placeholder="Agregar..."/>
|
- `list_canvas_snapshots` / `list_vue_components` — Listar lo guardado
|
||||||
<ul>
|
|
||||||
<li v-for="(item, i) in items" :key="i">{{ item }}</li>
|
**Edición:**
|
||||||
</ul>
|
- `edit_canvas` — Editar DOM in-place (selector + old_value → new_value)
|
||||||
</div>
|
- `get_canvas` / `get_canvas_css` — Inspeccionar estado actual
|
||||||
`,
|
|
||||||
imports: ["ref"],
|
### Viewport y Posicionamiento
|
||||||
setup: `
|
- Usar `browser-info` para screen size, pero NO es el viewport real
|
||||||
const items = ref(['Item 1', 'Item 2']);
|
- Para viewport exacto: renderizar un componente detector con window.innerWidth/Height
|
||||||
const nuevo = ref('');
|
- Las ventanas se posicionan en coordenadas absolutas (px)
|
||||||
const agregar = () => {
|
- Auto-cascada si no se especifica posición
|
||||||
if (nuevo.value) {
|
|
||||||
items.value.push(nuevo.value);
|
|
||||||
nuevo.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return { items, nuevo, agregar };
|
|
||||||
`
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vue Composition API
|
### Vue Composition API
|
||||||
|
Imports disponibles: ref, reactive, computed, watch, onMounted, onUnmounted
|
||||||
|
|
||||||
Imports disponibles:
|
Helpers globales en setup:
|
||||||
- `ref` - Valores reactivos
|
- `$emit(event, ...args)` / `$on(event, callback)` — Comunicación entre componentes
|
||||||
- `reactive` - Objetos reactivos
|
- `$fetch(url)` — HTTP requests
|
||||||
- `computed` - Valores computados
|
- `$theme.getVariable(name)` / `$theme.setVariable(name, value)`
|
||||||
- `watch` - Observar cambios
|
|
||||||
- `onMounted` - Hook de montaje
|
|
||||||
- `onUnmounted` - Hook de desmontaje
|
|
||||||
|
|
||||||
### 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:
|
### WebAssembly desde Cero
|
||||||
- `$emit(event, ...args)` - Emitir eventos
|
Puedo construir módulos WASM byte por byte sin compilador:
|
||||||
- `$on(event, callback)` - Escuchar eventos
|
- Builder: leb128 encoding + section builder + string encoder
|
||||||
- `$fetch(url)` - Hacer requests HTTP
|
- Secciones: Type(1), Function(3), Memory(5), Export(7), Code(10)
|
||||||
- `$theme.getVariable(name)` - Obtener variable CSS
|
- Opcodes que domino: local.get/set, i32.const/add/mul/xor/shr_u/and, i32.store8/load8_u, block/loop/br/br_if/end
|
||||||
- `$theme.setVariable(name, value)` - Cambiar variable CSS
|
|
||||||
|
|
||||||
---
|
### 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
|
### 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).
|
||||||
| 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Preferencias del Usuario
|
## 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).
|
- **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**.
|
- 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)
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
"repo": "anthropics/claude-plugins-official"
|
"repo": "anthropics/claude-plugins-official"
|
||||||
},
|
},
|
||||||
"installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,26 @@
|
|||||||
"mcp__agent-ui__z590_nucleoriofrio_com-activate_tool",
|
"mcp__agent-ui__z590_nucleoriofrio_com-activate_tool",
|
||||||
"mcp__agent-ui__z590_nucleoriofrio_com-list_available_tools",
|
"mcp__agent-ui__z590_nucleoriofrio_com-list_available_tools",
|
||||||
"mcp__agent-ui__z590_nucleoriofrio_com-page_refresh",
|
"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,
|
"enableAllProjectMcpServers": true,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
createResponseHandlers,
|
createResponseHandlers,
|
||||||
createGitHandlers,
|
createGitHandlers,
|
||||||
createTorchHandlers,
|
createTorchHandlers,
|
||||||
|
createSnapshotHandlers,
|
||||||
type ToolConfig
|
type ToolConfig
|
||||||
} from './tools/handlers'
|
} from './tools/handlers'
|
||||||
import { setRouter } from './tools/handlers/globalHandlers'
|
import { setRouter } from './tools/handlers/globalHandlers'
|
||||||
@@ -127,7 +128,8 @@ function getToolConfigs(): Map<string, ToolConfig> {
|
|||||||
...createTerminalHandlers(),
|
...createTerminalHandlers(),
|
||||||
...createResponseHandlers(),
|
...createResponseHandlers(),
|
||||||
...createGitHandlers(),
|
...createGitHandlers(),
|
||||||
...createTorchHandlers()
|
...createTorchHandlers(),
|
||||||
|
...createSnapshotHandlers()
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const config of allHandlers) {
|
for (const config of allHandlers) {
|
||||||
@@ -140,7 +142,7 @@ function getToolConfigs(): Map<string, ToolConfig> {
|
|||||||
// Category to tool names mapping
|
// Category to tool names mapping
|
||||||
const categoryTools: Record<ToolCategory, string[]> = {
|
const categoryTools: Record<ToolCategory, string[]> = {
|
||||||
global: ['get_current_page', 'navigate_to', 'list_available_tools', 'activate_tool', 'deactivate_tool', 'pin_tool', 'page_refresh'],
|
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'],
|
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'],
|
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'],
|
database: ['list_tables', 'get_table_schema', 'get_table_data', 'get_database_stats', 'execute_query'],
|
||||||
|
|||||||
@@ -6,6 +6,43 @@ import {
|
|||||||
type VueComponentDefinition
|
type VueComponentDefinition
|
||||||
} from '../../dynamicComponents'
|
} 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<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowDefinitions = new Map<string, WindowDefinitionEntry>()
|
||||||
|
|
||||||
|
export function getWindowDefinitions(): Map<string, WindowDefinitionEntry> {
|
||||||
|
return windowDefinitions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearWindowDefinitions(): void {
|
||||||
|
windowDefinitions.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
function getCanvasContainer() {
|
function getCanvasContainer() {
|
||||||
return document.getElementById('canvas-content')
|
return document.getElementById('canvas-content')
|
||||||
}
|
}
|
||||||
@@ -144,6 +181,13 @@ export function createCanvasHandlers(): ToolConfig[] {
|
|||||||
|
|
||||||
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend, layout)
|
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
|
;(window as any).__vueComponentUnmount = result.unmount
|
||||||
|
|
||||||
emitComponentRendered(args)
|
emitComponentRendered(args)
|
||||||
@@ -246,6 +290,7 @@ export function createCanvasHandlers(): ToolConfig[] {
|
|||||||
|
|
||||||
// Eliminar del store
|
// Eliminar del store
|
||||||
windowsStore.remove(args.id)
|
windowsStore.remove(args.id)
|
||||||
|
windowDefinitions.delete(args.id)
|
||||||
|
|
||||||
return `Ventana "${args.id}" cerrada`
|
return `Ventana "${args.id}" cerrada`
|
||||||
}
|
}
|
||||||
@@ -525,6 +570,9 @@ export function createCanvasHandlers(): ToolConfig[] {
|
|||||||
},
|
},
|
||||||
handler: async (args: { code: string; async?: boolean }) => {
|
handler: async (args: { code: string; async?: boolean }) => {
|
||||||
try {
|
try {
|
||||||
|
// Log script for snapshot capture
|
||||||
|
scriptLog.push(args.code)
|
||||||
|
|
||||||
const canvas = getCanvasContainer()
|
const canvas = getCanvasContainer()
|
||||||
const windowsStore = useWindowsStore()
|
const windowsStore = useWindowsStore()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
componentsApi,
|
componentsApi,
|
||||||
type VueComponentDefinition
|
type VueComponentDefinition
|
||||||
} from '../../dynamicComponents'
|
} from '../../dynamicComponents'
|
||||||
|
import { getWindowDefinitions } from './canvasHandlers'
|
||||||
|
|
||||||
function getCanvasContainer() {
|
function getCanvasContainer() {
|
||||||
return document.getElementById('canvas-content')
|
return document.getElementById('canvas-content')
|
||||||
@@ -80,6 +81,15 @@ export function createComponentHandlers(): ToolConfig[] {
|
|||||||
|
|
||||||
const isAppend = args.mode === 'append'
|
const isAppend = args.mode === 'append'
|
||||||
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
|
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
|
;(window as any).__vueComponentUnmount = result.unmount
|
||||||
|
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export { createResponseHandlers, setResponseControls } from './responseHandlers'
|
|||||||
export type { ResponseControls } from './responseHandlers'
|
export type { ResponseControls } from './responseHandlers'
|
||||||
export { createGitHandlers } from './gitHandlers'
|
export { createGitHandlers } from './gitHandlers'
|
||||||
export { createTorchHandlers } from './torchHandlers'
|
export { createTorchHandlers } from './torchHandlers'
|
||||||
|
export { createSnapshotHandlers } from './snapshotHandlers'
|
||||||
|
|
||||||
export type ToolHandler = (args: any) => string | Promise<string>
|
export type ToolHandler = (args: any) => string | Promise<string>
|
||||||
|
|
||||||
|
|||||||
95
frontend/src/services/tools/handlers/snapshotHandlers.ts
Normal file
95
frontend/src/services/tools/handlers/snapshotHandlers.ts
Normal file
@@ -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}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@ export const ALL_TOOL_METAS: ToolMeta[] = [
|
|||||||
{ name: 'canvas_css', description: 'Inyecta CSS en el canvas', category: 'canvas' },
|
{ name: 'canvas_css', description: 'Inyecta CSS en el canvas', category: 'canvas' },
|
||||||
{ name: 'canvas_js', description: 'Ejecuta JavaScript 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: '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
|
// Component tools
|
||||||
{ name: 'save_vue_component', description: 'Guarda un componente Vue en la base de datos', category: 'component' },
|
{ name: 'save_vue_component', description: 'Guarda un componente Vue en la base de datos', category: 'component' },
|
||||||
|
|||||||
284
frontend/src/stores/snapshots.ts
Normal file
284
frontend/src/stores/snapshots.ts
Normal file
@@ -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<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SnapshotSummary[]>([])
|
||||||
|
|
||||||
|
// ---- 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<string> {
|
||||||
|
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<SnapshotSummary[]> {
|
||||||
|
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<CanvasSnapshot> {
|
||||||
|
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<void> {
|
||||||
|
await fetch(`${API_URL}/api/snapshots/${id}`, { method: 'DELETE' })
|
||||||
|
await list()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Restore a snapshot ----
|
||||||
|
async function restore(id: string): Promise<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
// Voice recordings table (for training custom speech models)
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS voice_recordings (
|
CREATE TABLE IF NOT EXISTS voice_recordings (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { handleTables, handleStats, handleTableSchema, handleTableData, handleQu
|
|||||||
import { handleWhisperRoutes } from './whisper'
|
import { handleWhisperRoutes } from './whisper'
|
||||||
import { handleRecordingsRoutes } from './recordings'
|
import { handleRecordingsRoutes } from './recordings'
|
||||||
import { handleClaudeStatus } from './claude-status'
|
import { handleClaudeStatus } from './claude-status'
|
||||||
|
import { handleSnapshots, handleSnapshotById } from './snapshots'
|
||||||
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git'
|
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git'
|
||||||
|
|
||||||
export async function handleRequest(req: Request): Promise<Response> {
|
export async function handleRequest(req: Request): Promise<Response> {
|
||||||
@@ -145,6 +146,18 @@ export async function handleRequest(req: Request): Promise<Response> {
|
|||||||
if (res) return res
|
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
|
// Gitea
|
||||||
if (path === '/api/gitea/repo' && req.method === 'POST') {
|
if (path === '/api/gitea/repo' && req.method === 'POST') {
|
||||||
return handleGiteaRepo(req)
|
return handleGiteaRepo(req)
|
||||||
|
|||||||
49
server/routes/snapshots.ts
Normal file
49
server/routes/snapshots.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user