feat: Add dynamic MCP tool registration per page
- webmcp.ts: Add tool tracking, unregisterTool(), clearAllTools() - tools/canvasTools.ts: render_html, render_vue_component - tools/componentTools.ts: save/load/list/delete_vue_component - tools/themeTools.ts: get_design_tokens, get_active_theme, set_theme_variable, save_theme - tools/globalTools.ts: get_current_page, navigate_to, list_available_tools - toolRegistry.ts: Orchestrates tool registration per page - App.vue: Initializes WebMCP once, watches route for tool changes - Canvas.vue: Removed tool registration (now handled by registry) Tools are now registered when entering a page and unregistered when leaving. Refresh initializes tools correctly for the current page. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,316 +0,0 @@
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
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}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// get_design_tokens
|
||||
registerTool(
|
||||
'get_design_tokens',
|
||||
'Obtiene los design tokens y guía de estilos del tema activo. Usa esto para crear componentes con estilos consistentes.',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['all', 'colors', 'text', 'accent', 'semantic', 'spacing', 'typography', 'effects'],
|
||||
description: 'Categoría específica de tokens. Por defecto "all" retorna todos.'
|
||||
}
|
||||
}
|
||||
},
|
||||
async (args: { category?: string }) => {
|
||||
try {
|
||||
const themeStore = useThemeStore()
|
||||
const theme = themeStore.activeTheme
|
||||
|
||||
if (!theme) {
|
||||
return 'No hay tema activo. Usa las variables CSS por defecto.'
|
||||
}
|
||||
|
||||
const category = args.category || 'all'
|
||||
const variables = theme.variables
|
||||
|
||||
if (category !== 'all' && variables[category as keyof typeof variables]) {
|
||||
const categoryVars = variables[category as keyof typeof variables]
|
||||
const tokenList = Object.entries(categoryVars)
|
||||
.map(([name, value]) => `--${name}: ${value}`)
|
||||
.join('\n')
|
||||
|
||||
return `Design Tokens - ${category.toUpperCase()}:\n\n${tokenList}\n\nUsa estas variables CSS en tus estilos para mantener consistencia con el tema.`
|
||||
}
|
||||
|
||||
// Return all tokens organized by category
|
||||
const allTokens = Object.entries(variables)
|
||||
.map(([cat, vars]) => {
|
||||
const tokenList = Object.entries(vars as Record<string, string>)
|
||||
.map(([name, value]) => ` --${name}: ${value}`)
|
||||
.join('\n')
|
||||
return `[${cat.toUpperCase()}]\n${tokenList}`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
return `Design Tokens del tema "${theme.name}":\n\n${allTokens}\n\n` +
|
||||
`GUÍA DE USO:\n` +
|
||||
`- Usa var(--nombre-variable) en CSS\n` +
|
||||
`- Los componentes dinámicos tienen acceso a $theme.getVariable('nombre')\n` +
|
||||
`- Puedes modificar temporalmente con $theme.setVariable('nombre', 'valor')\n` +
|
||||
`- Colores semánticos: success, warning, error, info (con -bg para fondos)\n` +
|
||||
`- Radius: radius-sm (4px), radius-md (8px), radius-lg (12px), radius-full (9999px)`
|
||||
} catch (e: any) {
|
||||
return `Error: ${e.message}`
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
143
frontend/src/services/toolRegistry.ts
Normal file
143
frontend/src/services/toolRegistry.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { clearAllTools } from './webmcp'
|
||||
import {
|
||||
registerCanvasTools,
|
||||
unregisterCanvasTools,
|
||||
CANVAS_TOOLS
|
||||
} from './tools/canvasTools'
|
||||
import {
|
||||
registerComponentTools,
|
||||
unregisterComponentTools,
|
||||
COMPONENT_TOOLS
|
||||
} from './tools/componentTools'
|
||||
import {
|
||||
registerThemeTools,
|
||||
unregisterThemeTools,
|
||||
THEME_TOOLS
|
||||
} from './tools/themeTools'
|
||||
import {
|
||||
registerGlobalTools,
|
||||
setRouter,
|
||||
GLOBAL_TOOLS
|
||||
} from './tools/globalTools'
|
||||
|
||||
type PageName = 'canvas' | 'components' | 'themes'
|
||||
|
||||
interface PageToolSet {
|
||||
register: () => void
|
||||
unregister: () => void
|
||||
toolNames: string[]
|
||||
}
|
||||
|
||||
const pageTools: Record<PageName, PageToolSet> = {
|
||||
canvas: {
|
||||
register: () => {
|
||||
registerCanvasTools()
|
||||
registerComponentTools() // Canvas también puede guardar/cargar
|
||||
},
|
||||
unregister: () => {
|
||||
unregisterCanvasTools()
|
||||
unregisterComponentTools()
|
||||
},
|
||||
toolNames: [...CANVAS_TOOLS, ...COMPONENT_TOOLS]
|
||||
},
|
||||
components: {
|
||||
register: registerComponentTools,
|
||||
unregister: unregisterComponentTools,
|
||||
toolNames: COMPONENT_TOOLS
|
||||
},
|
||||
themes: {
|
||||
register: registerThemeTools,
|
||||
unregister: unregisterThemeTools,
|
||||
toolNames: THEME_TOOLS
|
||||
}
|
||||
}
|
||||
|
||||
let currentPage: PageName | null = null
|
||||
let isInitialized = false
|
||||
|
||||
/**
|
||||
* Inicializa el registry con el router de Vue
|
||||
*/
|
||||
export function initToolRegistry(router: any) {
|
||||
setRouter(router)
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Activa las tools para una página específica.
|
||||
* Desregistra las tools de otras páginas primero.
|
||||
*/
|
||||
export function activatePageTools(pageName: PageName) {
|
||||
if (!isInitialized) {
|
||||
console.warn('[ToolRegistry] Not initialized. Call initToolRegistry first.')
|
||||
return
|
||||
}
|
||||
|
||||
// Si ya estamos en esta página, no hacer nada
|
||||
if (currentPage === pageName) {
|
||||
console.log(`[ToolRegistry] Already on page "${pageName}", skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[ToolRegistry] Switching from "${currentPage}" to "${pageName}"`)
|
||||
|
||||
// Desregistrar tools de la página anterior
|
||||
if (currentPage && pageTools[currentPage]) {
|
||||
pageTools[currentPage].unregister()
|
||||
}
|
||||
|
||||
// Registrar tools de la nueva página
|
||||
if (pageTools[pageName]) {
|
||||
pageTools[pageName].register()
|
||||
}
|
||||
|
||||
// Asegurar que las tools globales estén registradas
|
||||
registerGlobalTools()
|
||||
|
||||
currentPage = pageName
|
||||
|
||||
console.log(`[ToolRegistry] Page "${pageName}" tools activated`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializa las tools para un refresh de página.
|
||||
* Limpia todo y registra las tools correctas.
|
||||
*/
|
||||
export function initToolsOnRefresh(pageName: PageName) {
|
||||
if (!isInitialized) {
|
||||
console.warn('[ToolRegistry] Not initialized. Call initToolRegistry first.')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[ToolRegistry] Initializing on refresh for page "${pageName}"`)
|
||||
|
||||
// Limpiar todas las tools existentes
|
||||
clearAllTools()
|
||||
|
||||
// Reset current page tracking
|
||||
currentPage = null
|
||||
|
||||
// Activar tools de la página actual
|
||||
activatePageTools(pageName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el nombre de la página actual
|
||||
*/
|
||||
export function getCurrentPage(): PageName | null {
|
||||
return currentPage
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene los nombres de las tools para una página
|
||||
*/
|
||||
export function getPageToolNames(pageName: PageName): string[] {
|
||||
return [...(pageTools[pageName]?.toolNames || []), ...GLOBAL_TOOLS]
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si el registry está inicializado
|
||||
*/
|
||||
export function isRegistryInitialized(): boolean {
|
||||
return isInitialized
|
||||
}
|
||||
146
frontend/src/services/tools/canvasTools.ts
Normal file
146
frontend/src/services/tools/canvasTools.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useCanvasStore } from '../../stores/canvas'
|
||||
import { registerTool, unregisterTools } from '../webmcp'
|
||||
import {
|
||||
renderInlineComponent,
|
||||
type VueComponentDefinition
|
||||
} from '../dynamicComponents'
|
||||
|
||||
export const CANVAS_TOOLS = ['render_html', 'render_vue_component']
|
||||
|
||||
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`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function unregisterCanvasTools() {
|
||||
unregisterTools(CANVAS_TOOLS)
|
||||
}
|
||||
147
frontend/src/services/tools/componentTools.ts
Normal file
147
frontend/src/services/tools/componentTools.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useCanvasStore } from '../../stores/canvas'
|
||||
import { registerTool, unregisterTools } from '../webmcp'
|
||||
import {
|
||||
renderInlineComponent,
|
||||
componentsApi,
|
||||
type VueComponentDefinition
|
||||
} from '../dynamicComponents'
|
||||
|
||||
export const COMPONENT_TOOLS = [
|
||||
'save_vue_component',
|
||||
'load_vue_component',
|
||||
'list_vue_components',
|
||||
'delete_vue_component'
|
||||
]
|
||||
|
||||
function getCanvasContainer() {
|
||||
return document.getElementById('canvas-content')
|
||||
}
|
||||
|
||||
function removePlaceholder(container: HTMLElement) {
|
||||
const placeholder = container.querySelector('.canvas-placeholder')
|
||||
if (placeholder) placeholder.remove()
|
||||
}
|
||||
|
||||
export function registerComponentTools() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// 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}`
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function unregisterComponentTools() {
|
||||
unregisterTools(COMPONENT_TOOLS)
|
||||
}
|
||||
107
frontend/src/services/tools/globalTools.ts
Normal file
107
frontend/src/services/tools/globalTools.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { registerTool, unregisterTools, getRegisteredTools } from '../webmcp'
|
||||
|
||||
export const GLOBAL_TOOLS = [
|
||||
'get_current_page',
|
||||
'navigate_to',
|
||||
'list_available_tools'
|
||||
]
|
||||
|
||||
let routerInstance: any = null
|
||||
|
||||
export function setRouter(router: any) {
|
||||
routerInstance = router
|
||||
}
|
||||
|
||||
export function registerGlobalTools() {
|
||||
// get_current_page
|
||||
registerTool(
|
||||
'get_current_page',
|
||||
'Obtiene la página actualmente activa en Agent UI',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
() => {
|
||||
if (!routerInstance) {
|
||||
return 'Error: Router no disponible'
|
||||
}
|
||||
|
||||
const route = routerInstance.currentRoute.value
|
||||
const pageInfo: Record<string, string> = {
|
||||
canvas: 'Canvas - Renderiza componentes Vue y HTML dinámicamente',
|
||||
components: 'Components - Gestiona componentes guardados en la base de datos',
|
||||
themes: 'Themes - Editor visual de temas y design tokens'
|
||||
}
|
||||
|
||||
const pageName = route.name as string || 'unknown'
|
||||
const description = pageInfo[pageName] || 'Página desconocida'
|
||||
|
||||
return `Página actual: ${pageName}\n` +
|
||||
`Ruta: ${route.path}\n` +
|
||||
`Descripción: ${description}\n\n` +
|
||||
`Herramientas disponibles en esta página:\n${getRegisteredTools().map(t => ` - ${t}`).join('\n')}`
|
||||
}
|
||||
)
|
||||
|
||||
// navigate_to
|
||||
registerTool(
|
||||
'navigate_to',
|
||||
'Navega a una página específica de Agent UI',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
page: {
|
||||
type: 'string',
|
||||
enum: ['canvas', 'components', 'themes'],
|
||||
description: 'Página a la que navegar'
|
||||
}
|
||||
},
|
||||
required: ['page']
|
||||
},
|
||||
async (args: { page: string }) => {
|
||||
if (!routerInstance) {
|
||||
return 'Error: Router no disponible'
|
||||
}
|
||||
|
||||
const routes: Record<string, string> = {
|
||||
canvas: '/',
|
||||
components: '/components',
|
||||
themes: '/themes'
|
||||
}
|
||||
|
||||
const path = routes[args.page]
|
||||
if (!path) {
|
||||
return `Error: Página "${args.page}" no válida. Opciones: canvas, components, themes`
|
||||
}
|
||||
|
||||
try {
|
||||
await routerInstance.push(path)
|
||||
return `Navegando a ${args.page} (${path})\n\n` +
|
||||
`Nota: Las herramientas MCP se actualizarán automáticamente para esta página.`
|
||||
} catch (e: any) {
|
||||
return `Error al navegar: ${e.message}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// list_available_tools
|
||||
registerTool(
|
||||
'list_available_tools',
|
||||
'Lista todas las herramientas MCP actualmente disponibles',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
() => {
|
||||
const tools = getRegisteredTools()
|
||||
if (tools.length === 0) {
|
||||
return 'No hay herramientas registradas'
|
||||
}
|
||||
return `Herramientas MCP disponibles (${tools.length}):\n${tools.map(t => ` - ${t}`).join('\n')}`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function unregisterGlobalTools() {
|
||||
unregisterTools(GLOBAL_TOOLS)
|
||||
}
|
||||
139
frontend/src/services/tools/themeTools.ts
Normal file
139
frontend/src/services/tools/themeTools.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useThemeStore } from '../../stores/theme'
|
||||
import { registerTool, unregisterTools } from '../webmcp'
|
||||
|
||||
export const THEME_TOOLS = [
|
||||
'get_design_tokens',
|
||||
'get_active_theme',
|
||||
'set_theme_variable'
|
||||
]
|
||||
|
||||
export function registerThemeTools() {
|
||||
// get_design_tokens
|
||||
registerTool(
|
||||
'get_design_tokens',
|
||||
'Obtiene los design tokens y guía de estilos del tema activo. Usa esto para crear componentes con estilos consistentes.',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['all', 'colors', 'text', 'accent', 'semantic', 'spacing', 'typography', 'effects'],
|
||||
description: 'Categoría específica de tokens. Por defecto "all" retorna todos.'
|
||||
}
|
||||
}
|
||||
},
|
||||
async (args: { category?: string }) => {
|
||||
try {
|
||||
const themeStore = useThemeStore()
|
||||
const theme = themeStore.activeTheme
|
||||
|
||||
if (!theme) {
|
||||
return 'No hay tema activo. Usa las variables CSS por defecto.'
|
||||
}
|
||||
|
||||
const category = args.category || 'all'
|
||||
const variables = theme.variables
|
||||
|
||||
if (category !== 'all' && variables[category as keyof typeof variables]) {
|
||||
const categoryVars = variables[category as keyof typeof variables]
|
||||
const tokenList = Object.entries(categoryVars)
|
||||
.map(([name, value]) => `--${name}: ${value}`)
|
||||
.join('\n')
|
||||
|
||||
return `Design Tokens - ${category.toUpperCase()}:\n\n${tokenList}\n\nUsa estas variables CSS en tus estilos para mantener consistencia con el tema.`
|
||||
}
|
||||
|
||||
// Return all tokens organized by category
|
||||
const allTokens = Object.entries(variables)
|
||||
.map(([cat, vars]) => {
|
||||
const tokenList = Object.entries(vars as Record<string, string>)
|
||||
.map(([name, value]) => ` --${name}: ${value}`)
|
||||
.join('\n')
|
||||
return `[${cat.toUpperCase()}]\n${tokenList}`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
return `Design Tokens del tema "${theme.name}":\n\n${allTokens}\n\n` +
|
||||
`GUÍA DE USO:\n` +
|
||||
`- Usa var(--nombre-variable) en CSS\n` +
|
||||
`- Los componentes dinámicos tienen acceso a $theme.getVariable('nombre')\n` +
|
||||
`- Puedes modificar temporalmente con $theme.setVariable('nombre', 'valor')\n` +
|
||||
`- Colores semánticos: success, warning, error, info (con -bg para fondos)\n` +
|
||||
`- Radius: radius-sm (4px), radius-md (8px), radius-lg (12px), radius-full (9999px)`
|
||||
} catch (e: any) {
|
||||
return `Error: ${e.message}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// get_active_theme
|
||||
registerTool(
|
||||
'get_active_theme',
|
||||
'Obtiene información del tema actualmente activo',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
() => {
|
||||
try {
|
||||
const themeStore = useThemeStore()
|
||||
const theme = themeStore.activeTheme
|
||||
|
||||
if (!theme) {
|
||||
return 'No hay tema activo'
|
||||
}
|
||||
|
||||
return `Tema activo: "${theme.name}"\n` +
|
||||
`ID: ${theme.id}\n` +
|
||||
`Sistema: ${theme.is_system ? 'Sí' : 'No'}\n` +
|
||||
`Default: ${theme.is_default ? 'Sí' : 'No'}\n` +
|
||||
`Descripción: ${theme.description || 'Sin descripción'}`
|
||||
} catch (e: any) {
|
||||
return `Error: ${e.message}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// set_theme_variable
|
||||
registerTool(
|
||||
'set_theme_variable',
|
||||
'Modifica una variable CSS del tema en tiempo real (cambio temporal, no se guarda)',
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Nombre de la variable sin el prefijo -- (ej: "accent", "bg-primary")'
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
description: 'Nuevo valor para la variable (ej: "#ff0000", "12px")'
|
||||
}
|
||||
},
|
||||
required: ['name', 'value']
|
||||
},
|
||||
(args: { name: string; value: string }) => {
|
||||
try {
|
||||
const root = document.documentElement
|
||||
const varName = args.name.startsWith('--') ? args.name : `--${args.name}`
|
||||
|
||||
// Get current value for feedback
|
||||
const currentValue = getComputedStyle(root).getPropertyValue(varName).trim()
|
||||
|
||||
// Set new value
|
||||
root.style.setProperty(varName, args.value)
|
||||
|
||||
return `Variable ${varName} cambiada:\n` +
|
||||
` Anterior: ${currentValue || '(no definida)'}\n` +
|
||||
` Nuevo: ${args.value}\n\n` +
|
||||
`Nota: Este cambio es temporal. Para hacerlo permanente, usa el editor de temas en /themes`
|
||||
} catch (e: any) {
|
||||
return `Error: ${e.message}`
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function unregisterThemeTools() {
|
||||
unregisterTools(THEME_TOOLS)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
|
||||
let webmcpInstance: any = null
|
||||
const registeredTools = new Set<string>()
|
||||
|
||||
export async function initWebMCP() {
|
||||
if (webmcpInstance) return webmcpInstance
|
||||
@@ -46,7 +47,51 @@ export function registerTool(
|
||||
) {
|
||||
if (!webmcpInstance) {
|
||||
console.warn('[WebMCP] Instance not initialized')
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (registeredTools.has(name)) {
|
||||
console.warn(`[WebMCP] Tool "${name}" already registered, skipping`)
|
||||
return false
|
||||
}
|
||||
webmcpInstance.registerTool(name, description, schema, handler)
|
||||
registeredTools.add(name)
|
||||
console.log(`[WebMCP] Tool registered: ${name}`)
|
||||
return true
|
||||
}
|
||||
|
||||
export function unregisterTool(name: string) {
|
||||
if (!webmcpInstance) {
|
||||
console.warn('[WebMCP] Instance not initialized')
|
||||
return false
|
||||
}
|
||||
if (!registeredTools.has(name)) {
|
||||
return false
|
||||
}
|
||||
webmcpInstance.unregisterTool(name)
|
||||
registeredTools.delete(name)
|
||||
console.log(`[WebMCP] Tool unregistered: ${name}`)
|
||||
return true
|
||||
}
|
||||
|
||||
export function unregisterTools(names: string[]) {
|
||||
for (const name of names) {
|
||||
unregisterTool(name)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllTools() {
|
||||
if (!webmcpInstance) return
|
||||
for (const name of registeredTools) {
|
||||
webmcpInstance.unregisterTool(name)
|
||||
}
|
||||
console.log(`[WebMCP] Cleared ${registeredTools.size} tools`)
|
||||
registeredTools.clear()
|
||||
}
|
||||
|
||||
export function getRegisteredTools(): string[] {
|
||||
return [...registeredTools]
|
||||
}
|
||||
|
||||
export function isToolRegistered(name: string): boolean {
|
||||
return registeredTools.has(name)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user