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:
@@ -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<string, ToolConfig> {
|
||||
...createTerminalHandlers(),
|
||||
...createResponseHandlers(),
|
||||
...createGitHandlers(),
|
||||
...createTorchHandlers()
|
||||
...createTorchHandlers(),
|
||||
...createSnapshotHandlers()
|
||||
]
|
||||
|
||||
for (const config of allHandlers) {
|
||||
@@ -140,7 +142,7 @@ function getToolConfigs(): Map<string, ToolConfig> {
|
||||
// Category to tool names mapping
|
||||
const categoryTools: Record<ToolCategory, string[]> = {
|
||||
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'],
|
||||
|
||||
@@ -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<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() {
|
||||
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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<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_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' },
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user