diff --git a/frontend/src/services/toolRegistry.ts b/frontend/src/services/toolRegistry.ts index 6c5db89..05706a4 100644 --- a/frontend/src/services/toolRegistry.ts +++ b/frontend/src/services/toolRegistry.ts @@ -26,6 +26,7 @@ import { createGitHandlers, createTorchHandlers, createSnapshotHandlers, + createFsComponentHandlers, type ToolConfig } from './tools/handlers' import { setRouter } from './tools/handlers/globalHandlers' @@ -134,7 +135,8 @@ function getToolConfigs(): Map { ...createResponseHandlers(), ...createGitHandlers(), ...createTorchHandlers(), - ...createSnapshotHandlers() + ...createSnapshotHandlers(), + ...createFsComponentHandlers() ] for (const config of allHandlers) { @@ -148,7 +150,7 @@ function getToolConfigs(): Map { const categoryTools: Record = { global: ['get_current_page', 'navigate_to', 'list_available_tools', 'activate_tool', 'deactivate_tool', 'pin_tool', 'page_refresh'], canvas: ['render_html', 'render_vue_component', 'move_window', 'resize_window', 'close_window', 'list_windows', 'inspect_window', 'get_canvas', 'edit_canvas', 'canvas_css', 'canvas_js', 'get_canvas_css', 'save_canvas_snapshot', 'load_canvas_snapshot', 'list_canvas_snapshots', 'delete_canvas_snapshot'], - component: ['save_vue_component', 'load_vue_component', 'list_vue_components', 'delete_vue_component', 'read_component', 'edit_component'], + component: ['list_fs_components', 'load_fs_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'], source: ['get_repo_info', 'list_repo_files', 'read_repo_file', 'search_repo_code'], diff --git a/frontend/src/services/tools/handlers/canvasHandlers.ts b/frontend/src/services/tools/handlers/canvasHandlers.ts index 9bb1b6a..0e49e24 100644 --- a/frontend/src/services/tools/handlers/canvasHandlers.ts +++ b/frontend/src/services/tools/handlers/canvasHandlers.ts @@ -3,7 +3,6 @@ import { useCanvasStore } from '../../../stores/canvas' import { useWindowsStore } from '../../../stores/windows' import { renderInlineComponent, - componentsApi, type VueComponentDefinition } from '../../dynamicComponents' @@ -24,7 +23,7 @@ export function clearScriptLog(): void { // WINDOW DEFINITIONS — tracks component definition per rendered window // ============================================ export interface WindowDefinitionEntry { - source: 'inline' | 'db' + source: 'inline' | 'db' | 'fs' componentId?: string definition: VueComponentDefinition componentProps: Record @@ -196,13 +195,6 @@ export function createCanvasHandlers(): ToolConfig[] { 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` diff --git a/frontend/src/services/tools/handlers/fsComponentHandlers.ts b/frontend/src/services/tools/handlers/fsComponentHandlers.ts new file mode 100644 index 0000000..f37fa84 --- /dev/null +++ b/frontend/src/services/tools/handlers/fsComponentHandlers.ts @@ -0,0 +1,113 @@ +import type { ToolConfig } from './index' +import { useCanvasStore } from '../../../stores/canvas' +import { + renderInlineComponent, + type VueComponentDefinition +} from '../../dynamicComponents' +import { getWindowDefinitions } from './canvasHandlers' + +const API_URL = '' + +export const fsComponentsApi = { + async list(): Promise { + const res = await fetch(`${API_URL}/api/fs-components`) + if (!res.ok) throw new Error(`Failed to list fs-components: ${res.statusText}`) + return res.json() + }, + + async getByFolder(folder: string): Promise { + const res = await fetch(`${API_URL}/api/fs-components/${encodeURIComponent(folder)}`) + if (!res.ok) throw new Error(`Component "${folder}" not found`) + return res.json() + } +} + +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')) + } +} + +export function createFsComponentHandlers(): ToolConfig[] { + return [ + { + name: 'list_fs_components', + description: 'Lista componentes Vue del filesystem (user-components/)', + category: 'component', + schema: { + type: 'object', + properties: {}, + required: [] + }, + handler: async () => { + try { + const components = await fsComponentsApi.list() + if (components.length === 0) { + return 'No hay componentes en user-components/. Crea una carpeta con un archivo .vue para empezar.' + } + const list = components.map(c => { + const tags = c.tags?.length ? ` [${c.tags.join(', ')}]` : '' + return `- ${c.folder}: ${c.name}${tags}` + }).join('\n') + return `Componentes en filesystem (${components.length}):\n${list}` + } catch (e: any) { + return `Error: ${e.message}` + } + } + }, + { + name: 'load_fs_component', + description: 'Carga y renderiza un componente desde user-components/', + category: 'component', + schema: { + type: 'object', + properties: { + folder: { type: 'string', description: 'Nombre de la carpeta del componente' }, + componentProps: { type: 'object', description: 'Props para el componente' }, + mode: { type: 'string', enum: ['replace', 'append'], description: 'Modo de renderizado' }, + x: { type: 'number', description: 'Posicion X inicial' }, + y: { type: 'number', description: 'Posicion Y inicial' }, + width: { type: 'number', description: 'Ancho inicial' }, + height: { type: 'number', description: 'Alto inicial' } + }, + required: ['folder'] + }, + handler: async (args: { folder: string; componentProps?: Record; mode?: string; x?: number; y?: number; width?: number; height?: number }) => { + try { + const definition = await fsComponentsApi.getByFolder(args.folder) + + const container = getCanvasContainer() + if (!container) return 'Error: canvas no encontrado' + + removePlaceholder(container) + + 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 + getWindowDefinitions().set(definition.id, { + source: 'fs', + componentId: args.folder, + definition, + componentProps: args.componentProps || {} + }) + + ;(window as any).__vueComponentUnmount = result.unmount + + const canvasStore = useCanvasStore() + canvasStore.addToHistory({ tool: 'load_fs_component', args, timestamp: Date.now() }) + return `Componente "${definition.name}" cargado desde user-components/${args.folder}/` + } catch (e: any) { + return `Error: ${e.message}` + } + } + } + ] +} diff --git a/frontend/src/services/tools/handlers/index.ts b/frontend/src/services/tools/handlers/index.ts index 8c83e4e..3b8d72d 100644 --- a/frontend/src/services/tools/handlers/index.ts +++ b/frontend/src/services/tools/handlers/index.ts @@ -17,6 +17,7 @@ export type { ResponseControls } from './responseHandlers' export { createGitHandlers } from './gitHandlers' export { createTorchHandlers } from './torchHandlers' export { createSnapshotHandlers } from './snapshotHandlers' +export { createFsComponentHandlers } from './fsComponentHandlers' export type ToolHandler = (args: any) => string | Promise diff --git a/frontend/src/services/tools/toolDefinitions.ts b/frontend/src/services/tools/toolDefinitions.ts index ceeb8e4..94e0f51 100644 --- a/frontend/src/services/tools/toolDefinitions.ts +++ b/frontend/src/services/tools/toolDefinitions.ts @@ -35,13 +35,9 @@ export const ALL_TOOL_METAS: ToolMeta[] = [ { name: 'list_canvas_snapshots', description: 'Lista snapshots del canvas guardados', category: 'canvas' }, { name: 'delete_canvas_snapshot', description: 'Elimina un snapshot del canvas', category: 'canvas' }, - // Component tools - { name: 'save_vue_component', description: 'Guarda un componente Vue en la base de datos', category: 'component' }, - { name: 'load_vue_component', description: 'Carga y renderiza un componente guardado', category: 'component' }, - { name: 'list_vue_components', description: 'Lista componentes guardados', category: 'component' }, - { name: 'delete_vue_component', description: 'Elimina un componente', category: 'component' }, - { name: 'read_component', description: 'Lee campos especificos de un componente guardado', category: 'component' }, - { name: 'edit_component', description: 'Edita un campo de componente con reemplazo de strings (requiere lectura previa)', category: 'component' }, + // Component tools (filesystem-based) + { name: 'list_fs_components', description: 'Lista componentes Vue del filesystem', category: 'component' }, + { name: 'load_fs_component', description: 'Carga y renderiza un componente desde user-components/', category: 'component' }, // Theme tools { name: 'get_design_tokens', description: 'Obtiene los design tokens del tema activo', category: 'theme' }, diff --git a/server/config.ts b/server/config.ts index 1abd7bb..9e1c878 100644 --- a/server/config.ts +++ b/server/config.ts @@ -15,3 +15,6 @@ export const DB_PATH = 'agent-ui.db' // Recordings export const RECORDINGS_DIR = 'recordings' + +// User components +export const USER_COMPONENTS_DIR = 'user-components' diff --git a/server/routes/fs-components.ts b/server/routes/fs-components.ts new file mode 100644 index 0000000..ba709cb --- /dev/null +++ b/server/routes/fs-components.ts @@ -0,0 +1,24 @@ +import { join } from 'path' +import { jsonResponse, errorResponse } from '../utils/cors' +import { USER_COMPONENTS_DIR, WORKING_DIR } from '../config' +import { listAllComponents, parseComponentFolder } from '../services/vue-parser' + +const baseDir = join(WORKING_DIR, USER_COMPONENTS_DIR) + +export function handleFsComponents(req: Request) { + if (req.method !== 'GET') return null + + const components = listAllComponents(baseDir) + return jsonResponse(components) +} + +export function handleFsComponentByName(req: Request, folderName: string) { + if (req.method !== 'GET') return null + + const component = parseComponentFolder(baseDir, folderName) + if (!component) { + return errorResponse(`Component folder "${folderName}" not found or has no .vue file`, 404) + } + + return jsonResponse(component) +} diff --git a/server/routes/index.ts b/server/routes/index.ts index b796800..bb4011d 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -3,6 +3,7 @@ import { handleHistory } from './history' import { handleConfig, handleHealth } from './config' import { handleWebMCPToken, handleWebMCPRequestToken } from './webmcp' import { handleComponents, handleComponentById, handleComponentUsage } from './components' +import { handleFsComponents, handleFsComponentByName } from './fs-components' import { handleThemes, handleActiveTheme, handleDesignTokens, handleThemeById, handleThemeExport } from './themes' import { handleCanvas, handleCanvasById, handleToolbarCanvas, handleDefaultCanvas, handleCanvasComponents, handleCanvasComponentById } from './canvas' import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea' @@ -107,6 +108,18 @@ export async function handleRequest(req: Request): Promise { if (res) return res } + // Filesystem components + if (path === '/api/fs-components') { + const res = handleFsComponents(req) + if (res) return res + } + + if (path.startsWith('/api/fs-components/')) { + const folderName = path.split('/').pop()! + const res = handleFsComponentByName(req, folderName) + if (res) return res + } + // Themes if (path === '/api/themes') { const res = await handleThemes(req) diff --git a/server/services/handlers/components-handler.ts b/server/services/handlers/components-handler.ts new file mode 100644 index 0000000..4d44107 --- /dev/null +++ b/server/services/handlers/components-handler.ts @@ -0,0 +1,67 @@ +/** + * Components Handler + * Watches user-components/ for .vue and .json file changes + * and broadcasts notifications via the sync server. + */ + +import { watch, mkdirSync, existsSync, type FSWatcher } from 'fs' +import { join } from 'path' +import { USER_COMPONENTS_DIR } from '../../config' + +let componentsWatcher: FSWatcher | null = null +let debounceTimer: ReturnType | null = null +const DEBOUNCE_MS = 400 + +export function setupComponentsWatcher(workingDir: string, broadcast: (message: string, filter?: (ws: any) => boolean) => void) { + const componentsDir = join(workingDir, USER_COMPONENTS_DIR) + + // Auto-create directory if it doesn't exist + if (!existsSync(componentsDir)) { + try { + mkdirSync(componentsDir, { recursive: true }) + } catch (e: any) { + console.error(`[Components] Failed to create ${componentsDir}: ${e.message}`) + return + } + } + + try { + componentsWatcher = watch(componentsDir, { recursive: true }, (_, filename) => { + if (!filename) return + + // Only watch .vue and .json files + if (!filename.endsWith('.vue') && !filename.endsWith('.json')) return + + // Debounce + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + // Extract folder name from path (first segment) + const folder = filename.split(/[/\\]/)[0] || '' + const file = filename.split(/[/\\]/).pop() || filename + + console.log(`[Components] Change: ${filename}`) + broadcast(JSON.stringify({ + type: 'component-change', + folder, + file, + timestamp: Date.now() + })) + }, DEBOUNCE_MS) + }) + + console.log(`[Components] Watching ${componentsDir}`) + } catch (e: any) { + console.error(`[Components] Watch failed: ${e.message}`) + } +} + +export function cleanupComponentsWatcher() { + if (componentsWatcher) { + componentsWatcher.close() + componentsWatcher = null + } + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } +} diff --git a/server/services/sync-server.ts b/server/services/sync-server.ts index 8a1fd4d..cea2b37 100644 --- a/server/services/sync-server.ts +++ b/server/services/sync-server.ts @@ -7,6 +7,7 @@ import { PORT_GIT, WORKING_DIR } from '../config' import { setupGitWatcher, handleGitClient, cleanupGitWatcher } from './handlers/git-handler' +import { setupComponentsWatcher, cleanupComponentsWatcher } from './handlers/components-handler' import { handleTorchMessage, handleTorchConnect, handleTorchDisconnect, getTorchStatus, cleanupTorchHandler } from './handlers/torch-handler' // Connected clients @@ -96,14 +97,16 @@ export function startSyncServer() { console.log(`[Sync] WebSocket server on port ${PORT_GIT}`) - // Start git file watcher + // Start file watchers setupGitWatcher(WORKING_DIR, broadcast) + setupComponentsWatcher(WORKING_DIR, broadcast) return server } export function stopSyncServer() { cleanupGitWatcher() + cleanupComponentsWatcher() cleanupTorchHandler() clients.clear() } diff --git a/server/services/vue-parser.ts b/server/services/vue-parser.ts new file mode 100644 index 0000000..803f905 --- /dev/null +++ b/server/services/vue-parser.ts @@ -0,0 +1,117 @@ +/** + * Vue File Parser + * Extracts