Files
agent-ui/frontend/src/services/tools/handlers/canvasHandlers.ts

655 lines
22 KiB
TypeScript

import type { ToolConfig } from './index'
import { useCanvasStore } from '../../../stores/canvas'
import { useWindowsStore } from '../../../stores/windows'
import {
renderInlineComponent,
componentsApi,
type VueComponentDefinition
} from '../../dynamicComponents'
// ============================================
// SCRIPT LOGGER — records every canvas_js execution for snapshots
// ============================================
const scriptLog: string[] = []
export function getScriptLog(): string[] {
return [...scriptLog]
}
export function clearScriptLog(): void {
scriptLog.length = 0
}
// ============================================
// WINDOW DEFINITIONS — tracks component definition per rendered window
// ============================================
export interface WindowDefinitionEntry {
source: 'inline' | 'db'
componentId?: string
definition: VueComponentDefinition
componentProps: Record<string, any>
}
const windowDefinitions = new Map<string, WindowDefinitionEntry>()
export function getWindowDefinitions(): Map<string, WindowDefinitionEntry> {
return windowDefinitions
}
export function clearWindowDefinitions(): void {
windowDefinitions.clear()
}
// ============================================
// HELPERS
// ============================================
function getCanvasContainer() {
return document.getElementById('canvas-content')
}
function removePlaceholder(container: HTMLElement) {
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) {
placeholder.remove()
window.dispatchEvent(new CustomEvent('canvas-content-rendered'))
}
}
function emitComponentRendered(args: any) {
window.dispatchEvent(new CustomEvent('vue-component-rendered', {
detail: {
id: args.id,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports
}
}))
}
export function createCanvasHandlers(): ToolConfig[] {
return [
{
name: 'render_html',
description: 'Renderiza HTML en el canvas. Soporta <script> y <style> tags.',
category: 'canvas',
schema: {
type: 'object',
properties: {
html: {
type: 'string',
description: 'El codigo HTML a renderizar'
},
mode: {
type: 'string',
enum: ['replace', 'append', 'prepend'],
description: 'Modo: replace, append, prepend'
}
},
required: ['html']
},
handler: (args: { html: string; mode?: string }) => {
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const mode = args.mode || 'replace'
if (mode === 'replace') {
container.innerHTML = args.html
} else if (mode === 'append') {
container.insertAdjacentHTML('beforeend', args.html)
} else if (mode === 'prepend') {
container.insertAdjacentHTML('afterbegin', args.html)
}
// Ejecutar scripts inline
const scripts = container.querySelectorAll('script')
scripts.forEach((oldScript) => {
const newScript = document.createElement('script')
Array.from(oldScript.attributes).forEach(attr => {
newScript.setAttribute(attr.name, attr.value)
})
newScript.textContent = oldScript.textContent
oldScript.parentNode?.replaceChild(newScript, oldScript)
})
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'render_html', args, timestamp: Date.now() })
return 'HTML renderizado'
}
},
{
name: 'render_vue_component',
description: 'Renderiza un componente Vue 3 en una ventana flotante con drag, resize y close.',
category: 'canvas',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID unico del componente' },
name: { type: 'string', description: 'Nombre del componente' },
template: { type: 'string', description: 'Template HTML con sintaxis Vue' },
setup: { type: 'string', description: 'Codigo de la funcion 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 a importar' },
componentProps: { type: 'object', description: 'Valores para las props' },
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']
},
handler: async (args: {
id: string
name: string
template: string
setup?: string
style?: string
props?: string[]
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'
removePlaceholder(container)
const definition: VueComponentDefinition = {
id: args.id,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports || ['ref', 'reactive', 'computed']
}
const isAppend = args.mode === 'append'
const layout = {
x: args.x,
y: args.y,
width: args.width,
height: args.height
}
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend, layout)
// Track definition for snapshot capture
windowDefinitions.set(args.id, {
source: 'inline',
definition,
componentProps: args.componentProps || {}
})
;(window as any).__vueComponentUnmount = result.unmount
emitComponentRendered(args)
// Auto-save component to database
try {
await componentsApi.save(definition)
} catch (e) {
console.warn('[auto-save] Failed to save component:', e)
}
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
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)
windowDefinitions.delete(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}`
}
},
{
name: 'inspect_window',
description: 'Inspecciona el contenido HTML de una ventana. Usa selector para filtrar elementos internos.',
category: 'canvas',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID de la ventana' },
selector: { type: 'string', description: 'Selector CSS dentro de la ventana' },
attribute: {
type: 'string',
enum: ['innerHTML', 'outerHTML', 'textContent', 'attributes'],
description: 'Que retornar (default: innerHTML)'
},
limit: { type: 'number', description: 'Max caracteres (default: 2000)' }
},
required: ['id']
},
handler: (args: { id: string; selector?: string; attribute?: string; limit?: number }) => {
const windowEl = document.querySelector(`[data-window-id="${args.id}"]`)
if (!windowEl) {
return `Error: window ${args.id} not found`
}
const content = windowEl.querySelector('.window-content')
if (!content) {
return `Error: no content in ${args.id}`
}
let target: Element | null = content
if (args.selector) {
target = content.querySelector(args.selector)
if (!target) {
return `Error: no match ${args.selector} in ${args.id}`
}
}
const attr = args.attribute || 'innerHTML'
const limit = args.limit || 2000
let result: string
if (attr === 'attributes') {
const attrs: Record<string, string> = {}
for (const a of Array.from(target.attributes)) {
attrs[a.name] = a.value
}
result = JSON.stringify(attrs, null, 2)
} else {
result = (target as any)[attr] || ''
}
if (result.length > limit) {
result = result.slice(0, limit) + `\n...(+${result.length - limit})`
}
return result
}
},
{
name: 'get_canvas',
description: 'Lee elementos del canvas con selector CSS',
category: 'canvas',
schema: {
type: 'object',
properties: {
selector: { type: 'string', description: 'Selector CSS' },
attribute: {
type: 'string',
enum: ['innerHTML', 'outerHTML', 'textContent', 'attributes', 'style', 'classList'],
description: 'Que retornar (default: innerHTML)'
},
all: { type: 'boolean', description: 'Retorna todos los matches (default: false)' },
limit: { type: 'number', description: 'Max caracteres (default: 3000)' }
},
required: ['selector']
},
handler: (args: { selector: string; attribute?: string; all?: boolean; limit?: number }) => {
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
const attr = args.attribute || 'innerHTML'
const limit = args.limit || 3000
const extractValue = (el: Element): string => {
if (attr === 'attributes') {
const attrs: Record<string, string> = {}
for (const a of Array.from(el.attributes)) {
attrs[a.name] = a.value
}
return JSON.stringify(attrs)
} else if (attr === 'style') {
return (el as HTMLElement).style.cssText
} else if (attr === 'classList') {
return Array.from(el.classList).join(' ')
} else {
return (el as any)[attr] || ''
}
}
let result: string
if (args.all) {
const elements = container.querySelectorAll(args.selector)
if (elements.length === 0) {
return `No match: ${args.selector}`
}
const values = Array.from(elements).map((el, i) => `[${i}] ${extractValue(el)}`)
result = `${elements.length} elements:\n${values.join('\n---\n')}`
} else {
const el = container.querySelector(args.selector)
if (!el) {
return `No match: ${args.selector}`
}
result = extractValue(el)
}
if (result.length > limit) {
result = result.slice(0, limit) + `\n...(+${result.length - limit})`
}
return result
}
},
{
name: 'edit_canvas',
description: 'Edita elementos del canvas (old_value -> new_value)',
category: 'canvas',
schema: {
type: 'object',
properties: {
selector: { type: 'string', description: 'Selector CSS' },
attribute: {
type: 'string',
enum: ['innerHTML', 'textContent', 'outerHTML', 'style', 'className'],
description: 'Que atributo editar (default: innerHTML)'
},
old_value: { type: 'string', description: 'Si se omite, reemplaza todo' },
new_value: { type: 'string', description: 'Nuevo valor' },
all: { type: 'boolean', description: 'Edita todos los matches (default: false)' }
},
required: ['selector', 'new_value']
},
handler: (args: { selector: string; attribute?: string; old_value?: string; new_value: string; all?: boolean }) => {
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
const attr = args.attribute || 'innerHTML'
const allElements = Array.from(container.querySelectorAll(args.selector))
if (allElements.length === 0) {
return `Error: no match ${args.selector}`
}
// Find which elements contain old_value
const matchingElements = args.old_value
? allElements.filter(el => {
const current = attr === 'style' ? (el as HTMLElement).style.cssText : (el as any)[attr]
return current && current.includes(args.old_value!)
})
: allElements
if (args.old_value && matchingElements.length === 0) {
return `Error: old_value not found`
}
if (!args.all && matchingElements.length > 1) {
return `Error: ${matchingElements.length} matches found. Use all:true or narrow selector.`
}
const elements = args.all ? matchingElements : [matchingElements[0]]
let editedCount = 0
for (const el of elements) {
const htmlEl = el as HTMLElement
if (args.old_value) {
const current = attr === 'style' ? htmlEl.style.cssText : (htmlEl as any)[attr]
const newVal = current.replace(args.old_value, args.new_value)
if (attr === 'style') {
htmlEl.style.cssText = newVal
} else {
(htmlEl as any)[attr] = newVal
}
} else {
if (attr === 'style') {
htmlEl.style.cssText = args.new_value
} else {
(htmlEl as any)[attr] = args.new_value
}
}
editedCount++
}
return `OK ${editedCount} edited`
}
},
{
name: 'canvas_css',
description: 'Inyecta CSS en el canvas',
category: 'canvas',
schema: {
type: 'object',
properties: {
css: { type: 'string', description: 'Codigo CSS' },
id: { type: 'string', description: 'ID del bloque (default: auto)' },
mode: {
type: 'string',
enum: ['inject', 'replace', 'remove'],
description: 'inject (default), replace, remove'
}
},
required: []
},
handler: (args: { css?: string; id?: string; mode?: string }) => {
const mode = args.mode || 'inject'
const styleId = args.id ? `canvas-css-${args.id}` : `canvas-css-${Date.now()}`
if (mode === 'remove') {
if (!args.id) return 'Error: id required for remove'
const existing = document.getElementById(`canvas-css-${args.id}`)
if (existing) {
existing.remove()
return `OK css:${args.id} removed`
}
return `Error: css:${args.id} not found`
}
if (!args.css) return 'Error: css required for inject/replace'
let styleEl = args.id ? document.getElementById(`canvas-css-${args.id}`) : null
if (mode === 'replace' && styleEl) {
styleEl.textContent = args.css
return `OK css:${args.id} replaced`
}
if (!styleEl) {
styleEl = document.createElement('style')
styleEl.id = styleId
document.head.appendChild(styleEl)
}
styleEl.textContent = args.css
return `OK css:${styleId.replace('canvas-css-', '')}`
}
},
{
name: 'canvas_js',
description: 'Ejecuta JavaScript en el contexto del canvas',
category: 'canvas',
schema: {
type: 'object',
properties: {
code: { type: 'string', description: 'Codigo JavaScript a ejecutar' },
async: { type: 'boolean', description: 'Ejecutar como async (default: false)' }
},
required: ['code']
},
handler: async (args: { code: string; async?: boolean }) => {
try {
// Log script for snapshot capture
scriptLog.push(args.code)
const canvas = getCanvasContainer()
const windowsStore = useWindowsStore()
// Contexto disponible para el codigo
const context = {
canvas,
windows: windowsStore.windowsList,
getWindow: (id: string) => document.querySelector(`[data-window-id="${id}"]`),
$: (selector: string) => canvas?.querySelector(selector),
$$: (selector: string) => canvas?.querySelectorAll(selector)
}
const fn = args.async
? new Function('ctx', `return (async () => { with(ctx) { ${args.code} } })()`)
: new Function('ctx', `with(ctx) { ${args.code} }`)
const result = args.async ? await fn(context) : fn(context)
return result !== undefined ? String(result) : 'Ejecutado'
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'get_canvas_css',
description: 'Obtiene CSS inyectado',
category: 'canvas',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del bloque (si se omite, lista todos)' }
},
required: []
},
handler: (args: { id?: string }) => {
if (args.id) {
const styleEl = document.getElementById(`canvas-css-${args.id}`)
if (!styleEl) return `Error: css:${args.id} not found`
return styleEl.textContent || '(empty)'
}
// Listar todos
const styles = document.querySelectorAll('style[id^="canvas-css-"]')
if (styles.length === 0) {
return 'No CSS injected'
}
const list = Array.from(styles).map(s => {
const id = s.id.replace('canvas-css-', '')
const preview = (s.textContent || '').slice(0, 100)
return `- ${id}: ${preview}${(s.textContent?.length || 0) > 100 ? '...' : ''}`
}).join('\n')
return `${styles.length} blocks:\n${list}`
}
}
]
}