refactor: Separate concerns and add components dropdown
- Add ComponentsDropdown.vue with save/load/delete functionality - Create components store (Pinia) for state management - Extract WebMCP initialization to services/webmcp.ts - Extract MCP tools registration to services/canvasTools.ts - Simplify Canvas.vue (~340 lines -> ~105 lines) - Update App.vue header with components dropdown Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
257
frontend/src/services/canvasTools.ts
Normal file
257
frontend/src/services/canvasTools.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
import { registerTool } from './webmcp'
|
||||
import {
|
||||
renderInlineComponent,
|
||||
componentsApi,
|
||||
type VueComponentDefinition
|
||||
} from './dynamicComponents'
|
||||
|
||||
function getCanvasContainer() {
|
||||
return document.getElementById('canvas-content')
|
||||
}
|
||||
|
||||
function removePlaceholder(container: HTMLElement) {
|
||||
const placeholder = container.querySelector('.canvas-placeholder')
|
||||
if (placeholder) placeholder.remove()
|
||||
}
|
||||
|
||||
function emitComponentRendered(args: any) {
|
||||
window.dispatchEvent(new CustomEvent('vue-component-rendered', {
|
||||
detail: {
|
||||
id: args.id,
|
||||
name: args.name,
|
||||
template: args.template,
|
||||
setup: args.setup,
|
||||
style: args.style,
|
||||
props: args.props,
|
||||
imports: args.imports
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
export function registerCanvasTools() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// render_html
|
||||
registerTool(
|
||||
'render_html',
|
||||
'Renderiza HTML en el canvas. Soporta <script> tags que se ejecutan automáticamente y <style> tags.',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
html: {
|
||||
type: 'string',
|
||||
description: 'El código HTML a renderizar (puede incluir <script> y <style> tags)'
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['replace', 'append', 'prepend'],
|
||||
description: 'Modo: replace (reemplaza), append (agrega al final), prepend (al inicio)'
|
||||
}
|
||||
},
|
||||
required: ['html']
|
||||
},
|
||||
(args: { html: string; mode?: string }) => {
|
||||
const container = getCanvasContainer()
|
||||
if (!container) return 'Error: canvas no encontrado'
|
||||
|
||||
removePlaceholder(container)
|
||||
|
||||
const mode = args.mode || 'replace'
|
||||
if (mode === 'replace') {
|
||||
container.innerHTML = args.html
|
||||
} else if (mode === 'append') {
|
||||
container.insertAdjacentHTML('beforeend', args.html)
|
||||
} else if (mode === 'prepend') {
|
||||
container.insertAdjacentHTML('afterbegin', args.html)
|
||||
}
|
||||
|
||||
// Ejecutar scripts inline
|
||||
const scripts = container.querySelectorAll('script')
|
||||
scripts.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)
|
||||
})
|
||||
|
||||
canvasStore.addToHistory({ tool: 'render_html', args, timestamp: Date.now() })
|
||||
return 'HTML renderizado'
|
||||
}
|
||||
)
|
||||
|
||||
// render_vue_component
|
||||
registerTool(
|
||||
'render_vue_component',
|
||||
'Renderiza un componente Vue 3 completo con acceso a ref, reactive, computed, watch, Pinia stores, etc.',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'ID único del componente' },
|
||||
name: { type: 'string', description: 'Nombre del componente (ej: MyCounter)' },
|
||||
template: { type: 'string', description: 'Template HTML del componente con sintaxis Vue' },
|
||||
setup: { type: 'string', description: 'Código de la función setup (debe retornar un objeto con las propiedades reactivas)' },
|
||||
style: { type: 'string', description: 'CSS del componente (opcional)' },
|
||||
props: { type: 'array', items: { type: 'string' }, description: 'Lista de props que acepta el componente' },
|
||||
imports: { type: 'array', items: { type: 'string' }, description: 'Funciones de Vue a importar: ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick, h' },
|
||||
componentProps: { type: 'object', description: 'Valores para las props del componente' },
|
||||
mode: { type: 'string', enum: ['replace', 'append'], description: 'replace: limpia el canvas, append: agrega al final' }
|
||||
},
|
||||
required: ['id', 'name', 'template']
|
||||
},
|
||||
(args: {
|
||||
id: string
|
||||
name: string
|
||||
template: string
|
||||
setup?: string
|
||||
style?: string
|
||||
props?: string[]
|
||||
imports?: string[]
|
||||
componentProps?: Record<string, any>
|
||||
mode?: string
|
||||
}) => {
|
||||
const container = getCanvasContainer()
|
||||
if (!container) return 'Error: canvas no encontrado'
|
||||
|
||||
removePlaceholder(container)
|
||||
|
||||
const definition: VueComponentDefinition = {
|
||||
id: args.id,
|
||||
name: args.name,
|
||||
template: args.template,
|
||||
setup: args.setup,
|
||||
style: args.style,
|
||||
props: args.props,
|
||||
imports: args.imports || ['ref', 'reactive', 'computed']
|
||||
}
|
||||
|
||||
const isAppend = args.mode === 'append'
|
||||
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
|
||||
|
||||
;(window as any).__vueComponentUnmount = result.unmount
|
||||
|
||||
emitComponentRendered(args)
|
||||
|
||||
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
|
||||
return `Componente Vue "${args.name}" renderizado correctamente`
|
||||
}
|
||||
)
|
||||
|
||||
// save_vue_component
|
||||
registerTool(
|
||||
'save_vue_component',
|
||||
'Guarda un componente Vue en la base de datos para reutilizarlo después',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'ID único del componente (se genera automáticamente si no se proporciona)' },
|
||||
name: { type: 'string', description: 'Nombre del componente' },
|
||||
template: { type: 'string', description: 'Template HTML del componente' },
|
||||
setup: { type: 'string', description: 'Código de la función setup' },
|
||||
style: { type: 'string', description: 'CSS del componente' },
|
||||
props: { type: 'array', items: { type: 'string' }, description: 'Lista de props' },
|
||||
imports: { type: 'array', items: { type: 'string' }, description: 'Funciones de Vue necesarias' }
|
||||
},
|
||||
required: ['name', 'template']
|
||||
},
|
||||
async (args: Omit<VueComponentDefinition, 'id'> & { id?: string }) => {
|
||||
try {
|
||||
const result = await componentsApi.save({
|
||||
id: args.id || `comp-${Date.now()}`,
|
||||
name: args.name,
|
||||
template: args.template,
|
||||
setup: args.setup,
|
||||
style: args.style,
|
||||
props: args.props,
|
||||
imports: args.imports
|
||||
})
|
||||
canvasStore.addToHistory({ tool: 'save_vue_component', args, timestamp: Date.now() })
|
||||
return `Componente "${args.name}" guardado con ID: ${result.id}`
|
||||
} catch (e: any) {
|
||||
return `Error al guardar: ${e.message}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// load_vue_component
|
||||
registerTool(
|
||||
'load_vue_component',
|
||||
'Carga un componente Vue guardado desde la base de datos y lo renderiza',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'ID del componente a cargar' },
|
||||
componentProps: { type: 'object', description: 'Props para pasar al componente' },
|
||||
mode: { type: 'string', enum: ['replace', 'append'], description: 'replace: limpia el canvas, append: agrega al final' }
|
||||
},
|
||||
required: ['id']
|
||||
},
|
||||
async (args: { id: string; componentProps?: Record<string, any>; mode?: string }) => {
|
||||
try {
|
||||
const definition = await componentsApi.getById(args.id)
|
||||
if (!definition) {
|
||||
return `Error: Componente con ID "${args.id}" no encontrado`
|
||||
}
|
||||
|
||||
const container = getCanvasContainer()
|
||||
if (!container) return 'Error: canvas no encontrado'
|
||||
|
||||
removePlaceholder(container)
|
||||
|
||||
const isAppend = args.mode === 'append'
|
||||
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
|
||||
;(window as any).__vueComponentUnmount = result.unmount
|
||||
|
||||
canvasStore.addToHistory({ tool: 'load_vue_component', args, timestamp: Date.now() })
|
||||
return `Componente "${definition.name}" cargado y renderizado`
|
||||
} catch (e: any) {
|
||||
return `Error: ${e.message}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// list_vue_components
|
||||
registerTool(
|
||||
'list_vue_components',
|
||||
'Lista todos los componentes Vue guardados en la base de datos',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const components = await componentsApi.getAll()
|
||||
if (components.length === 0) {
|
||||
return 'No hay componentes guardados'
|
||||
}
|
||||
const list = components.map(c => `- ${c.id}: ${c.name}`).join('\n')
|
||||
return `Componentes guardados:\n${list}`
|
||||
} catch (e: any) {
|
||||
return `Error: ${e.message}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// delete_vue_component
|
||||
registerTool(
|
||||
'delete_vue_component',
|
||||
'Elimina un componente Vue de la base de datos',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'ID del componente a eliminar' }
|
||||
},
|
||||
required: ['id']
|
||||
},
|
||||
async (args: { id: string }) => {
|
||||
try {
|
||||
await componentsApi.delete(args.id)
|
||||
return `Componente "${args.id}" eliminado`
|
||||
} catch (e: any) {
|
||||
return `Error: ${e.message}`
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
52
frontend/src/services/webmcp.ts
Normal file
52
frontend/src/services/webmcp.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
|
||||
let webmcpInstance: any = null
|
||||
|
||||
export async function initWebMCP() {
|
||||
if (webmcpInstance) return webmcpInstance
|
||||
|
||||
const WebMCPModule = await import('@nucleoriofrio/webmcp/src/webmcp.js')
|
||||
const WebMCP = WebMCPModule.default || WebMCPModule
|
||||
|
||||
webmcpInstance = new WebMCP({
|
||||
color: '#6366f1',
|
||||
position: 'bottom-right',
|
||||
inactivityTimeout: 60 * 60 * 1000 // 1 hora
|
||||
})
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
webmcpInstance.on?.('connected', () => {
|
||||
canvasStore.setConnected(true)
|
||||
})
|
||||
|
||||
webmcpInstance.on?.('disconnected', () => {
|
||||
canvasStore.setConnected(false)
|
||||
})
|
||||
|
||||
if (webmcpInstance.isConnected) {
|
||||
canvasStore.setConnected(true)
|
||||
}
|
||||
|
||||
// Exponer globalmente para debug
|
||||
;(window as any).webmcp = webmcpInstance
|
||||
|
||||
return webmcpInstance
|
||||
}
|
||||
|
||||
export function getWebMCP() {
|
||||
return webmcpInstance
|
||||
}
|
||||
|
||||
export function registerTool(
|
||||
name: string,
|
||||
description: string,
|
||||
schema: object,
|
||||
handler: Function
|
||||
) {
|
||||
if (!webmcpInstance) {
|
||||
console.warn('[WebMCP] Instance not initialized')
|
||||
return
|
||||
}
|
||||
webmcpInstance.registerTool(name, description, schema, handler)
|
||||
}
|
||||
Reference in New Issue
Block a user