feat: Add dynamic Vue 3 components system

- Add dynamicComponents.ts service (~300 lines)
  - CSS scoping with high specificity
  - Async setup support with Suspense
  - Event bus for inter-component communication
  - Shared Pinia store with main app
  - No app overhead (uses render + createVNode)

- Add MCP tools for Vue components
  - render_vue_component
  - save_vue_component
  - load_vue_component
  - list_vue_components
  - delete_vue_component

- Add SQLite table for component persistence
- Add TypeScript declarations for webmcp
- Configure Vite for runtime template compilation
- Add comprehensive README with documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 04:15:53 -06:00
parent 52c93930e1
commit 075e167389
8 changed files with 1054 additions and 3 deletions

View File

@@ -1,6 +1,11 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted } from 'vue'
import { useCanvasStore } from '../stores/canvas'
import {
renderInlineComponent,
componentsApi,
type VueComponentDefinition
} from '../services/dynamicComponents'
const canvasStore = useCanvasStore()
@@ -15,11 +20,24 @@ onMounted(async () => {
inactivityTimeout: 60 * 60 * 1000 // 1 hora
})
// Escuchar eventos de conexión
webmcp.on?.('connected', () => {
canvasStore.setConnected(true)
})
webmcp.on?.('disconnected', () => {
canvasStore.setConnected(false)
})
// Registrar herramientas para el canvas
registerCanvasTools(webmcp)
// Exponer webmcp globalmente para debug
;(window as any).webmcp = webmcp
// Verificar si ya está conectado
if (webmcp.isConnected) {
canvasStore.setConnected(true)
}
})
function registerCanvasTools(mcp: any) {
@@ -74,6 +92,247 @@ function registerCanvasTools(mcp: any) {
return 'HTML renderizado'
}
)
// render_vue_component: Renderiza un componente Vue 3 dinámico
mcp.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 = document.getElementById('canvas-content')
if (!container) return 'Error: canvas no encontrado'
// Quitar placeholder
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
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)
// Guardar referencia para cleanup
;(window as any).__vueComponentUnmount = result.unmount
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
return `Componente Vue "${args.name}" renderizado correctamente`
}
)
// save_vue_component: Guarda un componente en la base de datos
mcp.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: Carga y renderiza un componente guardado
mcp.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 = document.getElementById('canvas-content')
if (!container) return 'Error: canvas no encontrado'
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
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: Lista los componentes guardados
mcp.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: Elimina un componente
mcp.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}`
}
}
)
}
</script>

View File

@@ -3,6 +3,13 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import './styles/main.css'
const pinia = createPinia()
const app = createApp(App)
app.use(createPinia())
app.use(pinia)
// Exponer contexto global para componentes dinámicos
;(window as any).__vueApp = app
;(window as any).__pinia = pinia
app.mount('#app')

View File

@@ -0,0 +1,304 @@
import {
ref,
reactive,
computed,
watch,
watchEffect,
onMounted,
onUnmounted,
nextTick,
provide,
inject,
h,
render,
createVNode,
Suspense,
type App,
type Component
} from 'vue'
import { setActivePinia, type Pinia } from 'pinia'
import { useCanvasStore } from '../stores/canvas'
const API_URL = 'http://localhost:4101'
// Tipos
export interface VueComponentDefinition {
id: string
name: string
template: string
setup?: string
style?: string
props?: string[]
imports?: string[]
}
// ============================================
// EVENT BUS
// ============================================
type EventCallback = (...args: any[]) => void
class EventBus {
private events: Map<string, Set<EventCallback>> = new Map()
on(event: string, callback: EventCallback) {
if (!this.events.has(event)) {
this.events.set(event, new Set())
}
this.events.get(event)!.add(callback)
return () => this.off(event, callback)
}
off(event: string, callback: EventCallback) {
this.events.get(event)?.delete(callback)
}
emit(event: string, ...args: any[]) {
this.events.get(event)?.forEach(cb => cb(...args))
}
once(event: string, callback: EventCallback) {
const wrapper = (...args: any[]) => {
callback(...args)
this.off(event, wrapper)
}
this.on(event, wrapper)
}
}
export const eventBus = new EventBus()
;(window as any).__eventBus = eventBus
// ============================================
// CSS SCOPING
// ============================================
function generateScopeId(id: string): string {
return 'v-' + id.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8)
}
function scopeCSS(css: string, scopeId: string): string {
const scopePrefix = `#canvas-content [data-${scopeId}]`
return css.replace(
/([^{}]+)\{([^{}]*)\}/g,
(_match, selectors: string, rules: string) => {
const scopedSelectors = selectors
.split(',')
.map((selector: string) => {
selector = selector.trim()
if (!selector || selector.startsWith('@')) return selector
return `${scopePrefix} ${selector}`
})
.join(', ')
return `${scopedSelectors} {${rules}}`
}
)
}
function injectScopedStyle(css: string, scopeId: string, componentId: string): void {
const styleId = `style-${componentId}`
let styleEl = document.getElementById(styleId)
if (!styleEl) {
styleEl = document.createElement('style')
styleEl.id = styleId
document.head.appendChild(styleEl)
}
styleEl.textContent = scopeCSS(css, scopeId)
}
// ============================================
// COMPONENTS API
// ============================================
export const componentsApi = {
async getAll(): Promise<VueComponentDefinition[]> {
const res = await fetch(`${API_URL}/api/components`)
const data = await res.json()
return data.map((row: any) => ({
...row,
props: JSON.parse(row.props || '[]'),
imports: JSON.parse(row.imports || '[]')
}))
},
async getById(id: string): Promise<VueComponentDefinition | null> {
const res = await fetch(`${API_URL}/api/components/${id}`)
if (!res.ok) return null
const row = await res.json()
return {
...row,
props: JSON.parse(row.props || '[]'),
imports: JSON.parse(row.imports || '[]')
}
},
async save(component: VueComponentDefinition): Promise<{ success: boolean; id: string }> {
const res = await fetch(`${API_URL}/api/components`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(component)
})
return res.json()
},
async delete(id: string): Promise<{ success: boolean }> {
const res = await fetch(`${API_URL}/api/components/${id}`, { method: 'DELETE' })
return res.json()
},
async deleteAll(): Promise<{ success: boolean }> {
const res = await fetch(`${API_URL}/api/components`, { method: 'DELETE' })
return res.json()
}
}
// ============================================
// VUE EXPORTS & HELPERS
// ============================================
const vueExports = {
ref, reactive, computed, watch, watchEffect,
onMounted, onUnmounted, nextTick, provide, inject, h
}
function getCanvasStore() {
const globalPinia = (window as any).__pinia as Pinia | undefined
if (globalPinia) setActivePinia(globalPinia)
return useCanvasStore()
}
const dynamicHelpers = {
$emit: (event: string, ...args: any[]) => eventBus.emit(event, ...args),
$on: (event: string, cb: EventCallback) => eventBus.on(event, cb),
$once: (event: string, cb: EventCallback) => eventBus.once(event, cb),
$off: (event: string, cb: EventCallback) => eventBus.off(event, cb),
$fetch: fetch.bind(window),
$components: {
load: (id: string) => componentsApi.getById(id),
list: () => componentsApi.getAll(),
save: (comp: VueComponentDefinition) => componentsApi.save(comp),
},
useCanvasStore: getCanvasStore,
$nextTick: nextTick,
}
// ============================================
// BUILD COMPONENT
// ============================================
export function buildComponent(definition: VueComponentDefinition): Component {
const imports = definition.imports || ['ref', 'reactive', 'computed']
const vueImports: Record<string, any> = {}
imports.forEach(i => {
if (i in vueExports) vueImports[i] = (vueExports as any)[i]
})
const isAsync = definition.setup ? /\bawait\b/.test(definition.setup) : false
let setupFn: Function | null = null
if (definition.setup) {
try {
const fnBody = isAsync
? `return (async () => { ${definition.setup} })()`
: definition.setup
setupFn = new Function(
...Object.keys(vueImports),
'props',
'ctx',
...Object.keys(dynamicHelpers),
fnBody
)
} catch (e) {
console.error('[DynamicComponent] Error parsing setup:', e)
}
}
const componentDef: Component = {
name: definition.name,
props: definition.props || [],
template: definition.template
}
const executeSetup = (props: any, ctx: any) => {
if (!setupFn) return {}
return setupFn(
...Object.values(vueImports),
props,
ctx,
...Object.values(dynamicHelpers)
)
}
if (isAsync) {
componentDef.async = true
componentDef.setup = async (props: any, ctx: any) => await executeSetup(props, ctx)
} else {
componentDef.setup = executeSetup
}
return componentDef
}
// ============================================
// RENDER COMPONENT
// ============================================
const renderedContainers: Map<string, HTMLElement> = new Map()
export function renderInlineComponent(
definition: VueComponentDefinition,
target: HTMLElement,
props: Record<string, any> = {},
append: boolean = false
): { unmount: () => void } {
const scopeId = generateScopeId(definition.id)
const container = document.createElement('div')
container.id = `inline-${definition.id}`
container.className = 'dynamic-component-wrapper'
container.setAttribute(`data-${scopeId}`, '')
if (append) {
target.appendChild(container)
} else {
const oldContainer = renderedContainers.get(definition.id)
if (oldContainer) render(null, oldContainer)
target.innerHTML = ''
target.appendChild(container)
}
if (definition.style) {
injectScopedStyle(definition.style, scopeId, definition.id)
}
const isAsync = definition.setup ? /\bawait\b/.test(definition.setup) : false
const component = buildComponent(definition)
const vnode = isAsync
? createVNode(Suspense, null, {
default: () => createVNode(component, props),
fallback: () => createVNode('div', { class: 'loading' }, 'Loading...')
})
: createVNode(component, props)
const mainApp = (window as any).__vueApp as App | undefined
if (mainApp?._context) vnode.appContext = mainApp._context
render(vnode, container)
renderedContainers.set(definition.id, container)
return {
unmount: () => {
render(null, container)
renderedContainers.delete(definition.id)
container.remove()
document.getElementById(`style-${definition.id}`)?.remove()
}
}
}

28
frontend/src/types/webmcp.d.ts vendored Normal file
View File

@@ -0,0 +1,28 @@
declare module '@nucleoriofrio/webmcp/src/webmcp.js' {
interface WebMCPOptions {
color?: string
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'
inactivityTimeout?: number
}
interface ToolSchema {
type: string
properties: Record<string, any>
required?: string[]
}
type ToolHandler = (args: any) => string | Promise<string>
class WebMCP {
constructor(options?: WebMCPOptions)
registerTool(name: string, description: string, schema: ToolSchema, handler: ToolHandler): void
unregisterTool(name: string): void
connect(): Promise<void>
disconnect(): void
on?(event: 'connected' | 'disconnected' | string, callback: () => void): void
off?(event: string, callback: () => void): void
isConnected?: boolean
}
export default WebMCP
}