feat: Add floating window system for canvas components
- Add WindowContainer.vue with Liquid Glass styling, drag, resize, close - Add windows store for managing window state (position, size, z-index) - Modify dynamicComponents.ts to wrap Vue components in floating windows - Add MCP tools: move_window, resize_window, close_window, list_windows - Add isolated Claude profiles (ejecutor, nucleo000) with versioned configs
This commit is contained in:
21
.claude-ejecutor/.gitignore
vendored
Normal file
21
.claude-ejecutor/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Credenciales (NUNCA versionar)
|
||||
.credentials.json
|
||||
|
||||
# Estado interno de Claude (regenerable)
|
||||
.claude.json
|
||||
.claude.json.backup*
|
||||
|
||||
# Conversaciones y logs (muy grandes, privados)
|
||||
projects/**/*.jsonl
|
||||
history.jsonl
|
||||
debug/
|
||||
|
||||
# Cache y temporales
|
||||
cache/
|
||||
tmp/
|
||||
session-env/
|
||||
shell-snapshots/
|
||||
todos/
|
||||
|
||||
# Repos externos clonados
|
||||
plugins/marketplaces/*/
|
||||
150
.claude-ejecutor/CLAUDE.md
Normal file
150
.claude-ejecutor/CLAUDE.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Ejecutor - Instrucciones
|
||||
|
||||
## Rol
|
||||
Eres un agente especializado en manipular la interfaz de Agent UI exclusivamente a través de herramientas MCP.
|
||||
|
||||
## Reglas Estrictas
|
||||
|
||||
1. **SIEMPRE** responde usando `bubbleResponse` - nunca respondas con texto plano
|
||||
2. **SOLO** puedes usar herramientas MCP de `agent-ui`
|
||||
3. **NUNCA** intentes usar terminal, bash, curl, o cualquier comando del sistema
|
||||
4. **NUNCA** intentes leer, escribir o editar archivos
|
||||
5. Tu único propósito es manipular la interfaz gráfica
|
||||
|
||||
---
|
||||
|
||||
## Sistema de Canvas
|
||||
|
||||
El canvas es un área donde puedes renderizar componentes Vue. Cada componente se muestra en una **ventana flotante estilo Liquid Glass** con:
|
||||
- **Drag** - Arrastrar desde el header
|
||||
- **Resize** - Desde bordes y esquinas
|
||||
- **Close** - Botón rojo en el header
|
||||
|
||||
### render_vue_component
|
||||
|
||||
Renderiza un componente Vue 3 en una ventana flotante.
|
||||
|
||||
```js
|
||||
render_vue_component({
|
||||
// Requeridos
|
||||
id: "mi-componente", // ID único
|
||||
name: "Mi Componente", // Título de la ventana
|
||||
template: "<div>HTML con sintaxis Vue</div>",
|
||||
|
||||
// Opcionales
|
||||
setup: "const count = ref(0); return { count };",
|
||||
style: ".mi-clase { color: white; }",
|
||||
imports: ["ref", "reactive", "computed"],
|
||||
componentProps: { valor: 123 },
|
||||
|
||||
// Posición y tamaño (opcionales)
|
||||
x: 100, // Posición X (default: auto-cascada)
|
||||
y: 100, // Posición Y (default: auto-cascada)
|
||||
width: 300, // Ancho (default: 400)
|
||||
height: 200, // Alto (default: 300)
|
||||
|
||||
// Modo
|
||||
mode: "append" // "replace" limpia canvas, "append" agrega
|
||||
})
|
||||
```
|
||||
|
||||
### Ejemplos de Componentes
|
||||
|
||||
**Contador interactivo:**
|
||||
```js
|
||||
render_vue_component({
|
||||
id: "contador",
|
||||
name: "Contador",
|
||||
template: `
|
||||
<div style="text-align: center; color: white;">
|
||||
<h2>{{ count }}</h2>
|
||||
<button @click="count++">+1</button>
|
||||
</div>
|
||||
`,
|
||||
imports: ["ref"],
|
||||
setup: "const count = ref(0); return { count };",
|
||||
x: 100, y: 100, width: 200, height: 150
|
||||
})
|
||||
```
|
||||
|
||||
**Lista dinámica:**
|
||||
```js
|
||||
render_vue_component({
|
||||
id: "lista",
|
||||
name: "Lista",
|
||||
template: `
|
||||
<div style="color: white;">
|
||||
<input v-model="nuevo" @keyup.enter="agregar" placeholder="Agregar..."/>
|
||||
<ul>
|
||||
<li v-for="(item, i) in items" :key="i">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
imports: ["ref"],
|
||||
setup: `
|
||||
const items = ref(['Item 1', 'Item 2']);
|
||||
const nuevo = ref('');
|
||||
const agregar = () => {
|
||||
if (nuevo.value) {
|
||||
items.value.push(nuevo.value);
|
||||
nuevo.value = '';
|
||||
}
|
||||
};
|
||||
return { items, nuevo, agregar };
|
||||
`
|
||||
})
|
||||
```
|
||||
|
||||
### Vue Composition API
|
||||
|
||||
Imports disponibles:
|
||||
- `ref` - Valores reactivos
|
||||
- `reactive` - Objetos reactivos
|
||||
- `computed` - Valores computados
|
||||
- `watch` - Observar cambios
|
||||
- `onMounted` - Hook de montaje
|
||||
- `onUnmounted` - Hook de desmontaje
|
||||
|
||||
### Helpers Globales
|
||||
|
||||
En el setup tienes acceso a:
|
||||
- `$emit(event, ...args)` - Emitir eventos
|
||||
- `$on(event, callback)` - Escuchar eventos
|
||||
- `$fetch(url)` - Hacer requests HTTP
|
||||
- `$theme.getVariable(name)` - Obtener variable CSS
|
||||
- `$theme.setVariable(name, value)` - Cambiar variable CSS
|
||||
|
||||
---
|
||||
|
||||
## Otras Herramientas
|
||||
|
||||
| Herramienta | Uso |
|
||||
|-------------|-----|
|
||||
| `bubbleResponse` | Responder al usuario (OBLIGATORIO) |
|
||||
| `render_html` | Renderizar HTML plano |
|
||||
| `navigate_to` | Cambiar de página |
|
||||
| `page_refresh` | Refrescar página |
|
||||
| `get_design_tokens` | Obtener tokens del tema |
|
||||
| `set_theme_variable` | Cambiar variable del tema |
|
||||
| `switch_theme` | Cambiar tema activo |
|
||||
| `list_available_tools` | Ver herramientas disponibles |
|
||||
| `activate_tool` | Activar una herramienta |
|
||||
| `pin_tool` | Fijar herramienta |
|
||||
|
||||
---
|
||||
|
||||
## Formato de Respuesta
|
||||
|
||||
**SIEMPRE** usa bubbleResponse para comunicarte:
|
||||
```
|
||||
bubbleResponse({ message: "Tu mensaje aquí" })
|
||||
```
|
||||
|
||||
Nunca escribas texto directamente - todo debe ir a través de bubbleResponse.
|
||||
|
||||
---
|
||||
|
||||
## Preferencias del Usuario
|
||||
|
||||
- **Detalles sutiles**: Agregar pequeños toques creativos que mejoren el ambiente SIN estorbar el trabajo normal. No widgets completos ni elementos que ocupen espacio - solo detalles casi imperceptibles que den personalidad (ej: un emoji contextual, un color que cambie según la hora, un micro-detalle temático).
|
||||
- La clave es que el detalle **no interrumpa** ni **ocupe espacio útil**.
|
||||
10
.claude-ejecutor/plugins/known_marketplaces.json
Normal file
10
.claude-ejecutor/plugins/known_marketplaces.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"claude-plugins-official": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "anthropics/claude-plugins-official"
|
||||
},
|
||||
"installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official",
|
||||
"lastUpdated": "2026-02-15T01:40:47.405Z"
|
||||
}
|
||||
}
|
||||
22
.claude-ejecutor/settings.json
Normal file
22
.claude-ejecutor/settings.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"env": {
|
||||
"DISABLE_TELEMETRY": "1"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__agent-ui*"
|
||||
],
|
||||
"deny": [
|
||||
"Bash",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Read",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"Task",
|
||||
"NotebookEdit"
|
||||
]
|
||||
}
|
||||
}
|
||||
10
.claude-isolated/.gitignore
vendored
Normal file
10
.claude-isolated/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# No versionar credenciales
|
||||
.credentials.json
|
||||
*.backup
|
||||
|
||||
# Estado de sesión (regenerable)
|
||||
.claude.json
|
||||
.claude.json.backup
|
||||
|
||||
# Archivos temporales
|
||||
tmp/
|
||||
9
.claude-isolated/settings.json
Normal file
9
.claude-isolated/settings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [],
|
||||
"deny": []
|
||||
},
|
||||
"env": {
|
||||
"DISABLE_TELEMETRY": "1"
|
||||
}
|
||||
}
|
||||
21
.claude-nucleo000/.gitignore
vendored
Normal file
21
.claude-nucleo000/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Credenciales (NUNCA versionar)
|
||||
.credentials.json
|
||||
|
||||
# Estado interno de Claude (regenerable)
|
||||
.claude.json
|
||||
.claude.json.backup*
|
||||
|
||||
# Conversaciones y logs (muy grandes, privados)
|
||||
projects/**/*.jsonl
|
||||
history.jsonl
|
||||
debug/
|
||||
|
||||
# Cache y temporales
|
||||
cache/
|
||||
tmp/
|
||||
session-env/
|
||||
shell-snapshots/
|
||||
todos/
|
||||
|
||||
# Repos externos clonados
|
||||
plugins/marketplaces/*/
|
||||
9
.claude-nucleo000/settings.json
Normal file
9
.claude-nucleo000/settings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [],
|
||||
"deny": []
|
||||
},
|
||||
"env": {
|
||||
"DISABLE_TELEMETRY": "1"
|
||||
}
|
||||
}
|
||||
@@ -63,13 +63,15 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-placeholder {
|
||||
|
||||
407
frontend/src/components/WindowContainer.vue
Normal file
407
frontend/src/components/WindowContainer.vue
Normal file
@@ -0,0 +1,407 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
id: string
|
||||
title?: string
|
||||
x?: number
|
||||
y?: number
|
||||
width?: number
|
||||
height?: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
}>(), {
|
||||
title: 'Window',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 400,
|
||||
height: 300,
|
||||
minWidth: 200,
|
||||
minHeight: 100
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
move: [pos: { x: number; y: number }]
|
||||
resize: [size: { width: number; height: number }]
|
||||
focus: []
|
||||
}>()
|
||||
|
||||
// Estado interno de la ventana
|
||||
const currentX = ref(props.x)
|
||||
const currentY = ref(props.y)
|
||||
const currentWidth = ref(props.width)
|
||||
const currentHeight = ref(props.height)
|
||||
const zIndex = ref(100)
|
||||
|
||||
// Estado de drag
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartY = ref(0)
|
||||
const dragOffsetX = ref(0)
|
||||
const dragOffsetY = ref(0)
|
||||
|
||||
// Estado de resize
|
||||
const isResizing = ref(false)
|
||||
const resizeDirection = ref('')
|
||||
const resizeStartX = ref(0)
|
||||
const resizeStartY = ref(0)
|
||||
const resizeStartWidth = ref(0)
|
||||
const resizeStartHeight = ref(0)
|
||||
|
||||
const windowStyle = computed(() => ({
|
||||
left: `${currentX.value}px`,
|
||||
top: `${currentY.value}px`,
|
||||
width: `${currentWidth.value}px`,
|
||||
height: `${currentHeight.value}px`,
|
||||
zIndex: zIndex.value
|
||||
}))
|
||||
|
||||
// Drag handlers
|
||||
function startDrag(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).closest('.window-controls')) return
|
||||
|
||||
isDragging.value = true
|
||||
dragStartX.value = e.clientX
|
||||
dragStartY.value = e.clientY
|
||||
dragOffsetX.value = currentX.value
|
||||
dragOffsetY.value = currentY.value
|
||||
|
||||
bringToFront()
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
}
|
||||
|
||||
function onDrag(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
|
||||
const deltaX = e.clientX - dragStartX.value
|
||||
const deltaY = e.clientY - dragStartY.value
|
||||
|
||||
currentX.value = Math.max(0, dragOffsetX.value + deltaX)
|
||||
currentY.value = Math.max(0, dragOffsetY.value + deltaY)
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
if (isDragging.value) {
|
||||
isDragging.value = false
|
||||
emit('move', { x: currentX.value, y: currentY.value })
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
}
|
||||
|
||||
// Resize handlers
|
||||
function startResize(direction: string, e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
isResizing.value = true
|
||||
resizeDirection.value = direction
|
||||
resizeStartX.value = e.clientX
|
||||
resizeStartY.value = e.clientY
|
||||
resizeStartWidth.value = currentWidth.value
|
||||
resizeStartHeight.value = currentHeight.value
|
||||
|
||||
bringToFront()
|
||||
|
||||
document.addEventListener('mousemove', onResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
function onResize(e: MouseEvent) {
|
||||
if (!isResizing.value) return
|
||||
|
||||
const deltaX = e.clientX - resizeStartX.value
|
||||
const deltaY = e.clientY - resizeStartY.value
|
||||
|
||||
if (resizeDirection.value.includes('e')) {
|
||||
currentWidth.value = Math.max(props.minWidth, resizeStartWidth.value + deltaX)
|
||||
}
|
||||
|
||||
if (resizeDirection.value.includes('s')) {
|
||||
currentHeight.value = Math.max(props.minHeight, resizeStartHeight.value + deltaY)
|
||||
}
|
||||
|
||||
if (resizeDirection.value.includes('w')) {
|
||||
const newWidth = Math.max(props.minWidth, resizeStartWidth.value - deltaX)
|
||||
if (newWidth !== currentWidth.value) {
|
||||
currentX.value = resizeStartX.value + (resizeStartWidth.value - newWidth) + (e.clientX - resizeStartX.value) - deltaX
|
||||
currentWidth.value = newWidth
|
||||
}
|
||||
}
|
||||
|
||||
if (resizeDirection.value.includes('n')) {
|
||||
const newHeight = Math.max(props.minHeight, resizeStartHeight.value - deltaY)
|
||||
if (newHeight !== currentHeight.value) {
|
||||
currentY.value = resizeStartY.value + (resizeStartHeight.value - newHeight) + (e.clientY - resizeStartY.value) - deltaY
|
||||
currentHeight.value = newHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
if (isResizing.value) {
|
||||
isResizing.value = false
|
||||
emit('resize', { width: currentWidth.value, height: currentHeight.value })
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
// Z-index management
|
||||
let maxZIndex = 100
|
||||
|
||||
function bringToFront() {
|
||||
maxZIndex++
|
||||
zIndex.value = maxZIndex
|
||||
emit('focus')
|
||||
}
|
||||
|
||||
function handleWindowClick() {
|
||||
bringToFront()
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="window-container"
|
||||
:style="windowStyle"
|
||||
:data-window-id="id"
|
||||
@mousedown="handleWindowClick"
|
||||
>
|
||||
<!-- Header / Title bar -->
|
||||
<div class="window-header" @mousedown="startDrag">
|
||||
<span class="window-title">{{ title }}</span>
|
||||
<div class="window-controls">
|
||||
<button @click.stop="emit('close')" class="btn-close" title="Cerrar">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
||||
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="window-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Resize handles -->
|
||||
<div class="resize-handle resize-n" @mousedown="startResize('n', $event)"></div>
|
||||
<div class="resize-handle resize-e" @mousedown="startResize('e', $event)"></div>
|
||||
<div class="resize-handle resize-s" @mousedown="startResize('s', $event)"></div>
|
||||
<div class="resize-handle resize-w" @mousedown="startResize('w', $event)"></div>
|
||||
<div class="resize-handle resize-ne" @mousedown="startResize('ne', $event)"></div>
|
||||
<div class="resize-handle resize-se" @mousedown="startResize('se', $event)"></div>
|
||||
<div class="resize-handle resize-sw" @mousedown="startResize('sw', $event)"></div>
|
||||
<div class="resize-handle resize-nw" @mousedown="startResize('nw', $event)"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.window-container {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.2),
|
||||
inset 0 0.5px 0 rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 200px;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.window-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.window-title {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 59, 48, 0.7);
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-close svg {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background: rgba(255, 59, 48, 1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-close:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.window-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.window-content::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.window-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.window-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.window-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.window-content::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-n {
|
||||
top: 0;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 4px;
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.resize-e {
|
||||
right: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 4px;
|
||||
cursor: e-resize;
|
||||
}
|
||||
|
||||
.resize-s {
|
||||
bottom: 0;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 4px;
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.resize-w {
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 4px;
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
.resize-ne {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
.resize-se {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.resize-sw {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.resize-nw {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
/* Indicador visual de resize en esquinas */
|
||||
.resize-se::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 2px solid #444;
|
||||
border-bottom: 2px solid #444;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.window-container:hover .resize-se::after {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import { setActivePinia, type Pinia } from 'pinia'
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
import { useWindowsStore } from '../stores/windows'
|
||||
import WindowContainer from '../components/WindowContainer.vue'
|
||||
|
||||
// Uses relative URLs - works with Vite proxy in dev and Traefik in production
|
||||
const API_URL = ''
|
||||
@@ -183,6 +185,12 @@ function getThemeStore() {
|
||||
return useThemeStore()
|
||||
}
|
||||
|
||||
function getWindowsStore() {
|
||||
const globalPinia = (window as any).__pinia as Pinia | undefined
|
||||
if (globalPinia) setActivePinia(globalPinia)
|
||||
return useWindowsStore()
|
||||
}
|
||||
|
||||
const dynamicHelpers = {
|
||||
$emit: (event: string, ...args: any[]) => eventBus.emit(event, ...args),
|
||||
$on: (event: string, cb: EventCallback) => eventBus.on(event, cb),
|
||||
@@ -271,25 +279,47 @@ export function buildComponent(definition: VueComponentDefinition): Component {
|
||||
|
||||
const renderedContainers: Map<string, HTMLElement> = new Map()
|
||||
|
||||
export interface LayoutOptions {
|
||||
x?: number
|
||||
y?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function renderInlineComponent(
|
||||
definition: VueComponentDefinition,
|
||||
target: HTMLElement,
|
||||
props: Record<string, any> = {},
|
||||
append: boolean = false
|
||||
append: boolean = false,
|
||||
layout?: LayoutOptions
|
||||
): { unmount: () => void } {
|
||||
const scopeId = generateScopeId(definition.id)
|
||||
const windowsStore = getWindowsStore()
|
||||
|
||||
// Registrar ventana en el store
|
||||
const windowState = windowsStore.register(definition.id, {
|
||||
title: definition.name,
|
||||
x: layout?.x,
|
||||
y: layout?.y,
|
||||
width: layout?.width ?? 400,
|
||||
height: layout?.height ?? 300
|
||||
})
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.id = `inline-${definition.id}`
|
||||
container.className = 'dynamic-component-wrapper'
|
||||
container.setAttribute(`data-${scopeId}`, '')
|
||||
|
||||
// Siempre hacer append para ventanas flotantes
|
||||
if (append) {
|
||||
target.appendChild(container)
|
||||
} else {
|
||||
// En modo replace, limpiar contenedor anterior si existe
|
||||
const oldContainer = renderedContainers.get(definition.id)
|
||||
if (oldContainer) render(null, oldContainer)
|
||||
target.innerHTML = ''
|
||||
if (oldContainer) {
|
||||
render(null, oldContainer)
|
||||
oldContainer.remove()
|
||||
}
|
||||
target.appendChild(container)
|
||||
}
|
||||
|
||||
@@ -300,25 +330,52 @@ export function renderInlineComponent(
|
||||
const isAsync = definition.setup ? /\bawait\b/.test(definition.setup) : false
|
||||
const component = buildComponent(definition)
|
||||
|
||||
const vnode = isAsync
|
||||
// Función unmount que se llamará al cerrar la ventana
|
||||
const unmount = () => {
|
||||
render(null, container)
|
||||
renderedContainers.delete(definition.id)
|
||||
windowsStore.remove(definition.id)
|
||||
container.remove()
|
||||
document.getElementById(`style-${definition.id}`)?.remove()
|
||||
}
|
||||
|
||||
// Crear el componente interno
|
||||
const innerComponent = 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
|
||||
// Envolver en WindowContainer
|
||||
const windowVNode = createVNode(
|
||||
WindowContainer,
|
||||
{
|
||||
id: definition.id,
|
||||
title: definition.name,
|
||||
x: windowState.x,
|
||||
y: windowState.y,
|
||||
width: windowState.width,
|
||||
height: windowState.height,
|
||||
onClose: unmount,
|
||||
onMove: (pos: { x: number; y: number }) => {
|
||||
windowsStore.updatePosition(definition.id, pos.x, pos.y)
|
||||
},
|
||||
onResize: (size: { width: number; height: number }) => {
|
||||
windowsStore.updateSize(definition.id, size.width, size.height)
|
||||
},
|
||||
onFocus: () => {
|
||||
windowsStore.bringToFront(definition.id)
|
||||
}
|
||||
},
|
||||
{ default: () => innerComponent }
|
||||
)
|
||||
|
||||
render(vnode, container)
|
||||
const mainApp = (window as any).__vueApp as App | undefined
|
||||
if (mainApp?._context) windowVNode.appContext = mainApp._context
|
||||
|
||||
render(windowVNode, container)
|
||||
renderedContainers.set(definition.id, container)
|
||||
|
||||
return {
|
||||
unmount: () => {
|
||||
render(null, container)
|
||||
renderedContainers.delete(definition.id)
|
||||
container.remove()
|
||||
document.getElementById(`style-${definition.id}`)?.remove()
|
||||
}
|
||||
}
|
||||
return { unmount }
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ function getToolConfigs(): Map<string, ToolConfig> {
|
||||
// Category to tool names mapping
|
||||
const categoryTools: Record<ToolCategory, string[]> = {
|
||||
global: ['get_current_page', 'navigate_to', 'list_available_tools', 'activate_tool', 'deactivate_tool', 'pin_tool', 'page_refresh'],
|
||||
canvas: ['render_html', 'render_vue_component'],
|
||||
canvas: ['render_html', 'render_vue_component', 'move_window', 'resize_window', 'close_window', 'list_windows'],
|
||||
component: ['save_vue_component', 'load_vue_component', 'list_vue_components', 'delete_vue_component'],
|
||||
theme: ['get_design_tokens', 'get_active_theme', 'set_theme_variable', 'save_theme', 'list_themes', 'switch_theme', 'reset_theme'],
|
||||
database: ['list_tables', 'get_table_schema', 'get_table_data', 'get_database_stats', 'execute_query'],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ToolConfig } from './index'
|
||||
import { useCanvasStore } from '../../../stores/canvas'
|
||||
import { useWindowsStore } from '../../../stores/windows'
|
||||
import {
|
||||
renderInlineComponent,
|
||||
type VueComponentDefinition
|
||||
@@ -82,7 +83,7 @@ export function createCanvasHandlers(): ToolConfig[] {
|
||||
},
|
||||
{
|
||||
name: 'render_vue_component',
|
||||
description: 'Renderiza un componente Vue 3 completo con ref, reactive, computed, etc.',
|
||||
description: 'Renderiza un componente Vue 3 en una ventana flotante con drag, resize y close.',
|
||||
category: 'canvas',
|
||||
schema: {
|
||||
type: 'object',
|
||||
@@ -95,7 +96,11 @@ export function createCanvasHandlers(): ToolConfig[] {
|
||||
props: { type: 'array', items: { type: 'string' }, description: 'Lista de props' },
|
||||
imports: { type: 'array', items: { type: 'string' }, description: 'Funciones de Vue a importar' },
|
||||
componentProps: { type: 'object', description: 'Valores para las props' },
|
||||
mode: { type: 'string', enum: ['replace', 'append'], description: 'Modo de renderizado' }
|
||||
mode: { type: 'string', enum: ['replace', 'append'], description: 'Modo de renderizado' },
|
||||
x: { type: 'number', description: 'Posicion X inicial de la ventana' },
|
||||
y: { type: 'number', description: 'Posicion Y inicial de la ventana' },
|
||||
width: { type: 'number', description: 'Ancho inicial de la ventana' },
|
||||
height: { type: 'number', description: 'Alto inicial de la ventana' }
|
||||
},
|
||||
required: ['id', 'name', 'template']
|
||||
},
|
||||
@@ -109,6 +114,10 @@ export function createCanvasHandlers(): ToolConfig[] {
|
||||
imports?: string[]
|
||||
componentProps?: Record<string, any>
|
||||
mode?: string
|
||||
x?: number
|
||||
y?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}) => {
|
||||
const container = getCanvasContainer()
|
||||
if (!container) return 'Error: canvas no encontrado'
|
||||
@@ -126,7 +135,14 @@ export function createCanvasHandlers(): ToolConfig[] {
|
||||
}
|
||||
|
||||
const isAppend = args.mode === 'append'
|
||||
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
|
||||
const layout = {
|
||||
x: args.x,
|
||||
y: args.y,
|
||||
width: args.width,
|
||||
height: args.height
|
||||
}
|
||||
|
||||
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend, layout)
|
||||
|
||||
;(window as any).__vueComponentUnmount = result.unmount
|
||||
|
||||
@@ -134,7 +150,128 @@ export function createCanvasHandlers(): ToolConfig[] {
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
|
||||
return `Componente Vue "${args.name}" renderizado`
|
||||
return `Componente Vue "${args.name}" renderizado en ventana flotante`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'move_window',
|
||||
description: 'Mueve una ventana a una posicion especifica en el canvas.',
|
||||
category: 'canvas',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'ID de la ventana a mover' },
|
||||
x: { type: 'number', description: 'Nueva posicion X' },
|
||||
y: { type: 'number', description: 'Nueva posicion Y' }
|
||||
},
|
||||
required: ['id', 'x', 'y']
|
||||
},
|
||||
handler: (args: { id: string; x: number; y: number }) => {
|
||||
const windowsStore = useWindowsStore()
|
||||
|
||||
if (!windowsStore.has(args.id)) {
|
||||
return `Error: Ventana "${args.id}" no encontrada`
|
||||
}
|
||||
|
||||
windowsStore.updatePosition(args.id, args.x, args.y)
|
||||
|
||||
// Actualizar el DOM directamente
|
||||
const windowEl = document.querySelector(`[data-window-id="${args.id}"]`) as HTMLElement
|
||||
if (windowEl) {
|
||||
windowEl.style.left = `${args.x}px`
|
||||
windowEl.style.top = `${args.y}px`
|
||||
}
|
||||
|
||||
return `Ventana "${args.id}" movida a (${args.x}, ${args.y})`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'resize_window',
|
||||
description: 'Cambia el tamano de una ventana en el canvas.',
|
||||
category: 'canvas',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'ID de la ventana a redimensionar' },
|
||||
width: { type: 'number', description: 'Nuevo ancho' },
|
||||
height: { type: 'number', description: 'Nuevo alto' }
|
||||
},
|
||||
required: ['id', 'width', 'height']
|
||||
},
|
||||
handler: (args: { id: string; width: number; height: number }) => {
|
||||
const windowsStore = useWindowsStore()
|
||||
|
||||
if (!windowsStore.has(args.id)) {
|
||||
return `Error: Ventana "${args.id}" no encontrada`
|
||||
}
|
||||
|
||||
windowsStore.updateSize(args.id, args.width, args.height)
|
||||
|
||||
// Actualizar el DOM directamente
|
||||
const windowEl = document.querySelector(`[data-window-id="${args.id}"]`) as HTMLElement
|
||||
if (windowEl) {
|
||||
windowEl.style.width = `${args.width}px`
|
||||
windowEl.style.height = `${args.height}px`
|
||||
}
|
||||
|
||||
return `Ventana "${args.id}" redimensionada a ${args.width}x${args.height}`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'close_window',
|
||||
description: 'Cierra una ventana del canvas.',
|
||||
category: 'canvas',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'ID de la ventana a cerrar' }
|
||||
},
|
||||
required: ['id']
|
||||
},
|
||||
handler: (args: { id: string }) => {
|
||||
const windowsStore = useWindowsStore()
|
||||
|
||||
if (!windowsStore.has(args.id)) {
|
||||
return `Error: Ventana "${args.id}" no encontrada`
|
||||
}
|
||||
|
||||
// Buscar y eliminar el contenedor
|
||||
const container = document.getElementById(`inline-${args.id}`)
|
||||
if (container) {
|
||||
container.remove()
|
||||
}
|
||||
|
||||
// Eliminar estilos
|
||||
document.getElementById(`style-${args.id}`)?.remove()
|
||||
|
||||
// Eliminar del store
|
||||
windowsStore.remove(args.id)
|
||||
|
||||
return `Ventana "${args.id}" cerrada`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'list_windows',
|
||||
description: 'Lista todas las ventanas abiertas en el canvas.',
|
||||
category: 'canvas',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
},
|
||||
handler: () => {
|
||||
const windowsStore = useWindowsStore()
|
||||
const windows = windowsStore.windowsList
|
||||
|
||||
if (windows.length === 0) {
|
||||
return 'No hay ventanas abiertas'
|
||||
}
|
||||
|
||||
const list = windows.map(w =>
|
||||
`- ${w.id}: "${w.title}" en (${w.x}, ${w.y}) - ${w.width}x${w.height}`
|
||||
).join('\n')
|
||||
|
||||
return `Ventanas abiertas (${windows.length}):\n${list}`
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -20,6 +20,10 @@ export const ALL_TOOL_METAS: ToolMeta[] = [
|
||||
// Canvas tools
|
||||
{ name: 'render_html', description: 'Renderiza HTML en el canvas', category: 'canvas' },
|
||||
{ name: 'render_vue_component', description: 'Renderiza un componente Vue 3 completo', category: 'canvas' },
|
||||
{ name: 'move_window', description: 'Mueve una ventana a una posicion especifica', category: 'canvas' },
|
||||
{ name: 'resize_window', description: 'Cambia el tamano de una ventana', category: 'canvas' },
|
||||
{ name: 'close_window', description: 'Cierra una ventana del canvas', category: 'canvas' },
|
||||
{ name: 'list_windows', description: 'Lista todas las ventanas abiertas', category: 'canvas' },
|
||||
|
||||
// Component tools
|
||||
{ name: 'save_vue_component', description: 'Guarda un componente Vue en la base de datos', category: 'component' },
|
||||
@@ -110,5 +114,6 @@ export const CATEGORY_INFO: Record<ToolCategory, { label: string; color: string;
|
||||
source: { label: 'Source', color: '#8b5cf6', icon: 'M16 18l6-6-6-6M8 6l-6 6 6 6' },
|
||||
project: { label: 'Project', color: '#06b6d4', icon: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z' },
|
||||
terminal: { label: 'Terminal', color: '#22c55e', icon: 'M4 17l6-6-6-6M12 19h8' },
|
||||
git: { label: 'Git', color: '#f97316', icon: 'M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a9 9 0 0 1-9 9' }
|
||||
git: { label: 'Git', color: '#f97316', icon: 'M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a9 9 0 0 1-9 9' },
|
||||
torch: { label: 'Torch', color: '#eab308', icon: 'M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z' }
|
||||
}
|
||||
|
||||
125
frontend/src/stores/windows.ts
Normal file
125
frontend/src/stores/windows.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface WindowState {
|
||||
id: string
|
||||
title: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
zIndex: number
|
||||
}
|
||||
|
||||
export const useWindowsStore = defineStore('windows', () => {
|
||||
const windows = ref<Map<string, WindowState>>(new Map())
|
||||
const maxZIndex = ref(100)
|
||||
const windowOffset = ref(0)
|
||||
|
||||
// Getters
|
||||
const windowsList = computed(() => Array.from(windows.value.values()))
|
||||
const windowCount = computed(() => windows.value.size)
|
||||
|
||||
// Obtener siguiente posición para ventana nueva (cascada)
|
||||
function getNextPosition() {
|
||||
const offset = windowOffset.value * 30
|
||||
windowOffset.value = (windowOffset.value + 1) % 10
|
||||
return { x: 50 + offset, y: 50 + offset }
|
||||
}
|
||||
|
||||
// Registrar nueva ventana
|
||||
function register(id: string, state: Partial<WindowState> = {}) {
|
||||
const pos = state.x !== undefined && state.y !== undefined
|
||||
? { x: state.x, y: state.y }
|
||||
: getNextPosition()
|
||||
|
||||
windows.value.set(id, {
|
||||
id,
|
||||
title: state.title ?? id,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: state.width ?? 400,
|
||||
height: state.height ?? 300,
|
||||
zIndex: ++maxZIndex.value
|
||||
})
|
||||
|
||||
return windows.value.get(id)!
|
||||
}
|
||||
|
||||
// Traer ventana al frente
|
||||
function bringToFront(id: string) {
|
||||
const win = windows.value.get(id)
|
||||
if (win) {
|
||||
win.zIndex = ++maxZIndex.value
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar posición
|
||||
function updatePosition(id: string, x: number, y: number) {
|
||||
const win = windows.value.get(id)
|
||||
if (win) {
|
||||
win.x = x
|
||||
win.y = y
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar tamaño
|
||||
function updateSize(id: string, width: number, height: number) {
|
||||
const win = windows.value.get(id)
|
||||
if (win) {
|
||||
win.width = width
|
||||
win.height = height
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar título
|
||||
function updateTitle(id: string, title: string) {
|
||||
const win = windows.value.get(id)
|
||||
if (win) {
|
||||
win.title = title
|
||||
}
|
||||
}
|
||||
|
||||
// Eliminar ventana
|
||||
function remove(id: string) {
|
||||
windows.value.delete(id)
|
||||
}
|
||||
|
||||
// Obtener ventana por ID
|
||||
function get(id: string) {
|
||||
return windows.value.get(id)
|
||||
}
|
||||
|
||||
// Verificar si existe
|
||||
function has(id: string) {
|
||||
return windows.value.has(id)
|
||||
}
|
||||
|
||||
// Limpiar todas las ventanas
|
||||
function clear() {
|
||||
windows.value.clear()
|
||||
windowOffset.value = 0
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
windows,
|
||||
maxZIndex,
|
||||
|
||||
// Getters
|
||||
windowsList,
|
||||
windowCount,
|
||||
|
||||
// Actions
|
||||
register,
|
||||
bringToFront,
|
||||
updatePosition,
|
||||
updateSize,
|
||||
updateTitle,
|
||||
remove,
|
||||
get,
|
||||
has,
|
||||
clear,
|
||||
getNextPosition
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user