diff --git a/README.md b/README.md new file mode 100644 index 0000000..946ce60 --- /dev/null +++ b/README.md @@ -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: `
...
`, // 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: "
...
", + 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: ` +
+

{{ title }}

+

Count: {{ count }}

+ + +

Double: {{ doubled }}

+
+ `, + 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: ` +
+

Loading...

+
{{ data }}
+
+ `, + 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: ``, + setup: ` + const send = () => { + $emit('my-event', { message: 'Hello!', timestamp: Date.now() }); + }; + return { send }; + ` +} + +// Component B - Listener +{ + id: "listener", + name: "Listener", + template: `
Last event: {{ lastEvent }}
`, + 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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d0938b1..3e7381b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/src/components/Canvas.vue b/frontend/src/components/Canvas.vue index 8ccb16a..dcc4d47 100644 --- a/frontend/src/components/Canvas.vue +++ b/frontend/src/components/Canvas.vue @@ -1,6 +1,11 @@ diff --git a/frontend/src/main.ts b/frontend/src/main.ts index dcc6d11..e7b9b07 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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') diff --git a/frontend/src/services/dynamicComponents.ts b/frontend/src/services/dynamicComponents.ts new file mode 100644 index 0000000..a9d0baf --- /dev/null +++ b/frontend/src/services/dynamicComponents.ts @@ -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> = 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 { + 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 { + 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 = {} + 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 = new Map() + +export function renderInlineComponent( + definition: VueComponentDefinition, + target: HTMLElement, + props: Record = {}, + 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() + } + } +} diff --git a/frontend/src/types/webmcp.d.ts b/frontend/src/types/webmcp.d.ts new file mode 100644 index 0000000..f3e118d --- /dev/null +++ b/frontend/src/types/webmcp.d.ts @@ -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 + required?: string[] + } + + type ToolHandler = (args: any) => string | Promise + + class WebMCP { + constructor(options?: WebMCPOptions) + registerTool(name: string, description: string, schema: ToolSchema, handler: ToolHandler): void + unregisterTool(name: string): void + connect(): Promise + disconnect(): void + on?(event: 'connected' | 'disconnected' | string, callback: () => void): void + off?(event: string, callback: () => void): void + isConnected?: boolean + } + + export default WebMCP +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4ecc9e7..5c92c96 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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({ diff --git a/server/index.ts b/server/index.ts index d221087..0e24ac1 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 }) } })