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:
381
README.md
Normal file
381
README.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Agent UI
|
||||
|
||||
Dynamic canvas interface for Claude Code interaction via MCP (Model Context Protocol).
|
||||
|
||||
## Overview
|
||||
|
||||
Agent UI provides a visual canvas where Claude Code can render dynamic Vue 3 components, HTML content, and interactive UIs in real-time. It bridges the gap between CLI-based AI assistance and rich visual interfaces.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
agent-ui/
|
||||
├── frontend/ # Vue 3 + Vite + Pinia
|
||||
│ └── src/
|
||||
│ ├── components/ # Canvas, Toolbar, StatusBar
|
||||
│ ├── services/ # Dynamic components system
|
||||
│ ├── stores/ # Pinia state management
|
||||
│ └── styles/ # Global CSS
|
||||
├── server/ # Bun HTTP API + SQLite
|
||||
└── .mcp.json # MCP server configuration
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development (frontend + server)
|
||||
npm start
|
||||
```
|
||||
|
||||
- **Frontend**: http://localhost:4100
|
||||
- **API Server**: http://localhost:4101
|
||||
- **WebMCP**: ws://localhost:4102
|
||||
|
||||
## Connecting to Claude Code
|
||||
|
||||
1. Start the development server: `npm start`
|
||||
2. In Claude Code, run `/mcp` to reconnect
|
||||
3. Click the widget (bottom-right) in the browser
|
||||
4. Paste the token from Claude Code
|
||||
|
||||
---
|
||||
|
||||
# Dynamic Canvas
|
||||
|
||||
The dynamic canvas system allows Claude Code to render fully-featured Vue 3 components at runtime.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
### `render_vue_component`
|
||||
|
||||
Renders a Vue 3 component directly in the canvas.
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: "my-counter", // Unique component ID
|
||||
name: "MyCounter", // Component name
|
||||
template: `<div>...</div>`, // Vue template
|
||||
setup: `...`, // Composition API setup code
|
||||
style: `.class { ... }`, // Scoped CSS
|
||||
props: ["title"], // Props list
|
||||
imports: ["ref", "computed"], // Vue imports needed
|
||||
componentProps: { title: "Hello" }, // Props values
|
||||
mode: "replace" | "append" // Render mode
|
||||
}
|
||||
```
|
||||
|
||||
### `save_vue_component`
|
||||
|
||||
Saves a component to SQLite for later reuse.
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: "my-component", // Optional, auto-generated if empty
|
||||
name: "MyComponent",
|
||||
template: `...`,
|
||||
setup: `...`,
|
||||
style: `...`,
|
||||
props: [...],
|
||||
imports: [...]
|
||||
}
|
||||
```
|
||||
|
||||
### `load_vue_component`
|
||||
|
||||
Loads and renders a previously saved component.
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: "my-component",
|
||||
componentProps: { ... },
|
||||
mode: "replace" | "append"
|
||||
}
|
||||
```
|
||||
|
||||
### `list_vue_components`
|
||||
|
||||
Lists all saved components in the database.
|
||||
|
||||
### `delete_vue_component`
|
||||
|
||||
Deletes a component from the database.
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: "my-component"
|
||||
}
|
||||
```
|
||||
|
||||
### `render_html`
|
||||
|
||||
Renders raw HTML with script/style support (legacy, simpler option).
|
||||
|
||||
```javascript
|
||||
{
|
||||
html: "<div>...</div>",
|
||||
mode: "replace" | "append" | "prepend"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup Function API
|
||||
|
||||
Inside the `setup` string, components have access to:
|
||||
|
||||
### Vue Reactivity (via `imports`)
|
||||
|
||||
```javascript
|
||||
ref, reactive, computed, watch, watchEffect,
|
||||
onMounted, onUnmounted, nextTick, provide, inject, h
|
||||
```
|
||||
|
||||
### Props & Context
|
||||
|
||||
```javascript
|
||||
props // Component props
|
||||
ctx // { emit, attrs, slots, expose }
|
||||
```
|
||||
|
||||
### Event Bus
|
||||
|
||||
```javascript
|
||||
$emit(event, ...args) // Emit global event
|
||||
$on(event, callback) // Listen (returns unsubscribe fn)
|
||||
$once(event, callback) // Listen once
|
||||
$off(event, callback) // Stop listening
|
||||
```
|
||||
|
||||
### Utilities
|
||||
|
||||
```javascript
|
||||
$fetch(url, options) // Native fetch
|
||||
$nextTick(callback) // Vue nextTick
|
||||
useCanvasStore() // Pinia store (shared with main app)
|
||||
```
|
||||
|
||||
### Components API
|
||||
|
||||
```javascript
|
||||
$components.load(id) // Load component from DB
|
||||
$components.list() // List all components
|
||||
$components.save(comp) // Save component to DB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example: Interactive Counter
|
||||
|
||||
```javascript
|
||||
// MCP call: render_vue_component
|
||||
{
|
||||
id: "counter",
|
||||
name: "Counter",
|
||||
template: `
|
||||
<div class="counter">
|
||||
<h2>{{ title }}</h2>
|
||||
<p>Count: {{ count }}</p>
|
||||
<button @click="decrement">-</button>
|
||||
<button @click="increment">+</button>
|
||||
<p>Double: {{ doubled }}</p>
|
||||
</div>
|
||||
`,
|
||||
setup: `
|
||||
const count = ref(0);
|
||||
const doubled = computed(() => count.value * 2);
|
||||
|
||||
const increment = () => count.value++;
|
||||
const decrement = () => count.value--;
|
||||
|
||||
onMounted(() => {
|
||||
console.log('Counter mounted!');
|
||||
});
|
||||
|
||||
return { count, doubled, increment, decrement, title: props.title };
|
||||
`,
|
||||
style: `
|
||||
.counter { padding: 20px; background: #1e1e28; border-radius: 8px; }
|
||||
.counter button { margin: 0 5px; padding: 8px 16px; }
|
||||
`,
|
||||
props: ["title"],
|
||||
imports: ["ref", "computed", "onMounted"],
|
||||
componentProps: { title: "My Counter" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example: Async Data Fetching
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: "data-loader",
|
||||
name: "DataLoader",
|
||||
template: `
|
||||
<div class="loader">
|
||||
<p v-if="loading">Loading...</p>
|
||||
<pre v-else>{{ data }}</pre>
|
||||
</div>
|
||||
`,
|
||||
setup: `
|
||||
const loading = ref(true);
|
||||
const data = ref(null);
|
||||
|
||||
// Async setup - uses Suspense internally
|
||||
const res = await $fetch('http://localhost:4101/api/health');
|
||||
data.value = await res.json();
|
||||
loading.value = false;
|
||||
|
||||
return { loading, data };
|
||||
`,
|
||||
imports: ["ref"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example: Event Communication
|
||||
|
||||
```javascript
|
||||
// Component A - Emitter
|
||||
{
|
||||
id: "emitter",
|
||||
name: "Emitter",
|
||||
template: `<button @click="send">Send Event</button>`,
|
||||
setup: `
|
||||
const send = () => {
|
||||
$emit('my-event', { message: 'Hello!', timestamp: Date.now() });
|
||||
};
|
||||
return { send };
|
||||
`
|
||||
}
|
||||
|
||||
// Component B - Listener
|
||||
{
|
||||
id: "listener",
|
||||
name: "Listener",
|
||||
template: `<div>Last event: {{ lastEvent }}</div>`,
|
||||
setup: `
|
||||
const lastEvent = ref('none');
|
||||
|
||||
$on('my-event', (data) => {
|
||||
lastEvent.value = JSON.stringify(data);
|
||||
});
|
||||
|
||||
return { lastEvent };
|
||||
`,
|
||||
imports: ["ref"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### CSS Scoping
|
||||
|
||||
Styles are automatically scoped to prevent collisions:
|
||||
|
||||
```css
|
||||
/* You write: */
|
||||
.btn { background: red; }
|
||||
|
||||
/* Generated: */
|
||||
#canvas-content [data-v-abc123] .btn { background: red; }
|
||||
```
|
||||
|
||||
### Async Setup
|
||||
|
||||
Components can use `await` directly in setup - automatically wrapped in Suspense:
|
||||
|
||||
```javascript
|
||||
setup: `
|
||||
const data = await $fetch('/api/data');
|
||||
return { data };
|
||||
`
|
||||
```
|
||||
|
||||
### Shared State
|
||||
|
||||
Components share Pinia stores with the main app:
|
||||
|
||||
```javascript
|
||||
setup: `
|
||||
const store = useCanvasStore();
|
||||
console.log(store.isConnected); // Same state as main app
|
||||
`
|
||||
```
|
||||
|
||||
### No App Overhead
|
||||
|
||||
Components are rendered using `render()` + `createVNode()` instead of creating separate Vue apps, sharing the main app's context efficiently.
|
||||
|
||||
---
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Vite Configuration
|
||||
|
||||
Runtime template compilation requires this alias in `vite.config.ts`:
|
||||
|
||||
```typescript
|
||||
resolve: {
|
||||
alias: {
|
||||
'vue': 'vue/dist/vue.esm-bundler.js'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **MCP Tool Call** → Claude Code sends component definition via WebSocket
|
||||
2. **Build Component** → `buildComponent()` creates Vue component with dynamic `setup` function using `new Function()`
|
||||
3. **CSS Scoping** → Styles are transformed: `.btn` → `#canvas-content [data-v-xxx] .btn`
|
||||
4. **Render** → `createVNode()` + `render()` mounts component without creating new Vue app
|
||||
5. **Context Inheritance** → Component inherits `appContext` from main app (Pinia, directives, etc.)
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
frontend/src/services/dynamicComponents.ts (~300 lines)
|
||||
├── EventBus # Inter-component communication
|
||||
├── CSS Scoping # Style isolation
|
||||
├── Components API # CRUD with server
|
||||
├── Vue Helpers # What setup() receives
|
||||
├── buildComponent # Create component from definition
|
||||
└── renderInlineComponent # Mount to DOM
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/health` | GET | Health check |
|
||||
| `/api/history` | GET | Get tool execution history |
|
||||
| `/api/history` | POST | Log tool execution |
|
||||
| `/api/history` | DELETE | Clear history |
|
||||
| `/api/components` | GET | List saved components |
|
||||
| `/api/components` | POST | Save component |
|
||||
| `/api/components/:id` | GET | Get component by ID |
|
||||
| `/api/components/:id` | DELETE | Delete component |
|
||||
| `/api/config` | GET/POST | Key-value config storage |
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Vue 3, Vite, Pinia, TypeScript
|
||||
- **Server**: Bun, SQLite
|
||||
- **MCP**: @nucleoriofrio/webmcp
|
||||
- **PWA**: vite-plugin-pwa
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
2
frontend/package-lock.json
generated
2
frontend/package-lock.json
generated
@@ -1405,7 +1405,7 @@
|
||||
},
|
||||
"node_modules/@nucleoriofrio/webmcp": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git#480d4d618e04a679d249609089df72bc4bb41643",
|
||||
"resolved": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git#d5a912be97dbcf49adf5dc055fd437d5653ef5d0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
304
frontend/src/services/dynamicComponents.ts
Normal file
304
frontend/src/services/dynamicComponents.ts
Normal 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
28
frontend/src/types/webmcp.d.ts
vendored
Normal 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
|
||||
}
|
||||
@@ -3,6 +3,12 @@ import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
// Habilitar compilación de templates en runtime para componentes dinámicos
|
||||
'vue': 'vue/dist/vue.esm-bundler.js'
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
|
||||
@@ -22,6 +22,21 @@ db.run(`
|
||||
)
|
||||
`)
|
||||
|
||||
// Tabla para componentes Vue dinámicos
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS vue_components (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
template TEXT NOT NULL,
|
||||
setup TEXT,
|
||||
style TEXT,
|
||||
props TEXT,
|
||||
imports TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
console.log('[DB] SQLite inicializado: agent-ui.db')
|
||||
|
||||
// API HTTP solamente - WebSocket lo maneja webmcp
|
||||
@@ -85,6 +100,57 @@ Bun.serve({
|
||||
return Response.json({ status: 'ok', timestamp: new Date().toISOString() }, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// API de Componentes Vue
|
||||
if (url.pathname === '/api/components') {
|
||||
if (req.method === 'GET') {
|
||||
const rows = db.query('SELECT * FROM vue_components ORDER BY updated_at DESC').all()
|
||||
return Response.json(rows, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json()
|
||||
const id = body.id || `comp-${Date.now()}`
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO vue_components
|
||||
(id, name, template, setup, style, props, imports, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`)
|
||||
stmt.run(
|
||||
id,
|
||||
body.name,
|
||||
body.template,
|
||||
body.setup || '',
|
||||
body.style || '',
|
||||
JSON.stringify(body.props || []),
|
||||
JSON.stringify(body.imports || [])
|
||||
)
|
||||
return Response.json({ success: true, id }, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
db.run('DELETE FROM vue_components')
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener componente por ID
|
||||
if (url.pathname.startsWith('/api/components/')) {
|
||||
const id = url.pathname.split('/').pop()
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const row = db.query('SELECT * FROM vue_components WHERE id = ?').get(id)
|
||||
if (!row) {
|
||||
return Response.json({ error: 'Component not found' }, { status: 404, headers: corsHeaders })
|
||||
}
|
||||
return Response.json(row, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
db.run('DELETE FROM vue_components WHERE id = ?', [id])
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404, headers: corsHeaders })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user