import { Database } from 'bun:sqlite' import { spawn, type IPty } from '@skitee3000/bun-pty' const PORT_HTTP = 4101 const PORT_TERMINAL = 4103 // Terminal types interface TerminalSession { id: string pty: IPty outputBuffer: string[] // Buffer para replay al reconectar maxBufferSize: number clients: Set // WebSockets conectados a esta sesión createdAt: Date } // Inicializar base de datos const db = new Database('agent-ui.db') db.run(` CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT DEFAULT CURRENT_TIMESTAMP, tool_name TEXT NOT NULL, args TEXT, result TEXT ) `) db.run(` CREATE TABLE IF NOT EXISTS config ( key TEXT PRIMARY KEY, value TEXT ) `) // 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 ) `) // Tabla para temas/estilos db.run(` CREATE TABLE IF NOT EXISTS themes ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, is_default INTEGER DEFAULT 0, is_system INTEGER DEFAULT 0, variables TEXT NOT NULL, metadata TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) `) // Tabla para project canvas db.run(` CREATE TABLE IF NOT EXISTS project_canvas ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, type TEXT NOT NULL DEFAULT 'project', theme_id TEXT, config TEXT, tools TEXT, is_default INTEGER DEFAULT 0, is_system INTEGER DEFAULT 0, show_in_toolbar INTEGER DEFAULT 0, toolbar_icon TEXT, toolbar_order INTEGER DEFAULT 99, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) `) // Migrar tabla existente si falta la columna try { db.run(`ALTER TABLE project_canvas ADD COLUMN show_in_toolbar INTEGER DEFAULT 0`) db.run(`ALTER TABLE project_canvas ADD COLUMN toolbar_icon TEXT`) db.run(`ALTER TABLE project_canvas ADD COLUMN toolbar_order INTEGER DEFAULT 99`) } catch (e) { // Columnas ya existen } // Tabla para relación canvas-componentes db.run(` CREATE TABLE IF NOT EXISTS canvas_components ( id INTEGER PRIMARY KEY AUTOINCREMENT, canvas_id TEXT NOT NULL, component_id TEXT NOT NULL, position INTEGER DEFAULT 0, props TEXT, layout TEXT, is_visible INTEGER DEFAULT 1, created_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(canvas_id, component_id) ) `) // Insertar temas del sistema si no existen const existingThemes = db.query('SELECT COUNT(*) as count FROM themes WHERE is_system = 1').get() as { count: number } if (existingThemes.count === 0) { const darkTheme = { id: 'theme-dark', name: 'Dark', description: 'Default dark theme', is_default: 1, is_system: 1, variables: JSON.stringify({ colors: { 'bg-primary': '#0f0f14', 'bg-secondary': '#16161d', 'bg-hover': '#1e1e28', 'bg-tertiary': '#252530', 'border-color': '#2a2a3a', 'border-hover': '#3a3a4a' }, text: { 'text-primary': '#e4e4e7', 'text-secondary': '#a1a1aa', 'text-muted': '#52525b', 'text-inverse': '#0f0f14' }, accent: { 'accent': '#6366f1', 'accent-hover': '#818cf8', 'accent-muted': 'rgba(99, 102, 241, 0.2)', 'accent-text': '#ffffff' }, semantic: { 'success': '#22c55e', 'success-bg': 'rgba(34, 197, 94, 0.1)', 'warning': '#eab308', 'warning-bg': 'rgba(234, 179, 8, 0.1)', 'error': '#ef4444', 'error-bg': 'rgba(239, 68, 68, 0.1)', 'info': '#3b82f6', 'info-bg': 'rgba(59, 130, 246, 0.1)' }, spacing: { 'radius-sm': '4px', 'radius-md': '8px', 'radius-lg': '12px', 'radius-full': '9999px' }, typography: { 'font-sans': "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", 'font-mono': "'JetBrains Mono', 'Fira Code', Consolas, monospace" }, effects: { 'shadow-sm': '0 1px 2px rgba(0,0,0,0.2)', 'shadow-md': '0 4px 6px rgba(0,0,0,0.3)', 'shadow-lg': '0 10px 15px rgba(0,0,0,0.4)', 'transition-fast': '0.15s ease', 'transition-normal': '0.2s ease' } }), metadata: JSON.stringify({ author: 'system', version: '1.0.0', tags: ['dark', 'default'] }) } const lightTheme = { id: 'theme-light', name: 'Light', description: 'Clean light theme', is_default: 0, is_system: 1, variables: JSON.stringify({ colors: { 'bg-primary': '#ffffff', 'bg-secondary': '#f4f4f5', 'bg-hover': '#e4e4e7', 'bg-tertiary': '#d4d4d8', 'border-color': '#d4d4d8', 'border-hover': '#a1a1aa' }, text: { 'text-primary': '#18181b', 'text-secondary': '#52525b', 'text-muted': '#a1a1aa', 'text-inverse': '#ffffff' }, accent: { 'accent': '#4f46e5', 'accent-hover': '#4338ca', 'accent-muted': 'rgba(79, 70, 229, 0.1)', 'accent-text': '#ffffff' }, semantic: { 'success': '#16a34a', 'success-bg': 'rgba(22, 163, 74, 0.1)', 'warning': '#ca8a04', 'warning-bg': 'rgba(202, 138, 4, 0.1)', 'error': '#dc2626', 'error-bg': 'rgba(220, 38, 38, 0.1)', 'info': '#2563eb', 'info-bg': 'rgba(37, 99, 235, 0.1)' }, spacing: { 'radius-sm': '4px', 'radius-md': '8px', 'radius-lg': '12px', 'radius-full': '9999px' }, typography: { 'font-sans': "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", 'font-mono': "'JetBrains Mono', 'Fira Code', Consolas, monospace" }, effects: { 'shadow-sm': '0 1px 2px rgba(0,0,0,0.05)', 'shadow-md': '0 4px 6px rgba(0,0,0,0.07)', 'shadow-lg': '0 10px 15px rgba(0,0,0,0.1)', 'transition-fast': '0.15s ease', 'transition-normal': '0.2s ease' } }), metadata: JSON.stringify({ author: 'system', version: '1.0.0', tags: ['light'] }) } const stmt = db.prepare(` INSERT INTO themes (id, name, description, is_default, is_system, variables, metadata) VALUES (?, ?, ?, ?, ?, ?, ?) `) stmt.run(darkTheme.id, darkTheme.name, darkTheme.description, darkTheme.is_default, darkTheme.is_system, darkTheme.variables, darkTheme.metadata) stmt.run(lightTheme.id, lightTheme.name, lightTheme.description, lightTheme.is_default, lightTheme.is_system, lightTheme.variables, lightTheme.metadata) console.log('[DB] Temas del sistema creados') } console.log('[DB] SQLite inicializado: agent-ui.db') // API HTTP solamente - WebSocket lo maneja webmcp Bun.serve({ port: PORT_HTTP, async fetch(req) { const url = new URL(req.url) // CORS headers const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' } if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }) } // API Routes if (url.pathname === '/api/history') { if (req.method === 'GET') { const limit = parseInt(url.searchParams.get('limit') || '50') const rows = db.query('SELECT * FROM history ORDER BY id DESC LIMIT ?').all(limit) return Response.json(rows, { headers: corsHeaders }) } if (req.method === 'POST') { const body = await req.json() const stmt = db.prepare('INSERT INTO history (tool_name, args, result) VALUES (?, ?, ?)') stmt.run(body.tool_name, JSON.stringify(body.args), body.result) return Response.json({ success: true }, { headers: corsHeaders }) } if (req.method === 'DELETE') { db.run('DELETE FROM history') return Response.json({ success: true }, { headers: corsHeaders }) } } if (url.pathname === '/api/config') { if (req.method === 'GET') { const key = url.searchParams.get('key') if (key) { const row = db.query('SELECT value FROM config WHERE key = ?').get(key) as { value: string } | null return Response.json({ value: row?.value || null }, { headers: corsHeaders }) } const rows = db.query('SELECT * FROM config').all() return Response.json(rows, { headers: corsHeaders }) } if (req.method === 'POST') { const body = await req.json() const stmt = db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)') stmt.run(body.key, body.value) return Response.json({ success: true }, { headers: corsHeaders }) } } if (url.pathname === '/api/health') { 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/') && !url.pathname.includes('/usage')) { 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') { // Verificar si el componente está en uso por algún canvas const usage = db.query(` SELECT pc.id, pc.name FROM canvas_components cc JOIN project_canvas pc ON cc.canvas_id = pc.id WHERE cc.component_id = ? `).all(id) as { id: string; name: string }[] if (usage.length > 0) { return Response.json({ error: 'Component in use', message: `Cannot delete component. It is used by: ${usage.map(u => u.name).join(', ')}`, usedBy: usage }, { status: 409, headers: corsHeaders }) } db.run('DELETE FROM vue_components WHERE id = ?', [id]) return Response.json({ success: true }, { headers: corsHeaders }) } } // ===================== // API de Temas // ===================== // GET /api/themes - Lista todos los temas // POST /api/themes - Crea o actualiza un tema if (url.pathname === '/api/themes') { if (req.method === 'GET') { const rows = db.query('SELECT * FROM themes ORDER BY is_system DESC, is_default DESC, name ASC').all() const themes = (rows as any[]).map(row => ({ ...row, is_default: !!row.is_default, is_system: !!row.is_system, variables: JSON.parse(row.variables), metadata: row.metadata ? JSON.parse(row.metadata) : null })) return Response.json(themes, { headers: corsHeaders }) } if (req.method === 'POST') { const body = await req.json() const id = body.id || `theme-${Date.now()}` const stmt = db.prepare(` INSERT OR REPLACE INTO themes (id, name, description, is_default, is_system, variables, metadata, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `) stmt.run( id, body.name, body.description || '', body.is_default ? 1 : 0, body.is_system ? 1 : 0, JSON.stringify(body.variables), JSON.stringify(body.metadata || {}) ) return Response.json({ success: true, id }, { headers: corsHeaders }) } } // GET /api/themes/active - Obtiene el tema activo (default) if (url.pathname === '/api/themes/active') { if (req.method === 'GET') { const row = db.query('SELECT * FROM themes WHERE is_default = 1 LIMIT 1').get() as any if (!row) { return Response.json({ error: 'No active theme' }, { status: 404, headers: corsHeaders }) } return Response.json({ ...row, is_default: !!row.is_default, is_system: !!row.is_system, variables: JSON.parse(row.variables), metadata: row.metadata ? JSON.parse(row.metadata) : null }, { headers: corsHeaders }) } } // GET /api/design-tokens - Guía de design tokens para LLMs if (url.pathname === '/api/design-tokens') { if (req.method === 'GET') { const row = db.query('SELECT variables FROM themes WHERE is_default = 1 LIMIT 1').get() as { variables: string } | null const tokens = row ? JSON.parse(row.variables) : {} return Response.json({ version: '1.0.0', description: 'Design tokens for Agent UI components. Use these CSS variables for consistent styling.', usage: 'Use var(--token-name) in CSS, e.g., var(--bg-primary)', tokens, guidelines: { backgrounds: 'Use bg-primary for main areas, bg-secondary for cards/panels, bg-tertiary for nested elements', text: 'Use text-primary for headings, text-secondary for body, text-muted for hints', accent: 'Use accent for interactive elements, accent-hover for hover states, accent-muted for backgrounds', semantic: 'Use success/warning/error/info for status indicators with their -bg variants for backgrounds', spacing: 'Use radius-sm (4px) for small elements, radius-md (8px) for cards, radius-lg (12px) for modals', effects: 'Use transition-fast for micro-interactions, shadow-md for elevated elements' }, examples: { button: 'background: var(--accent); color: var(--accent-text); border-radius: var(--radius-md); transition: var(--transition-fast);', card: 'background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm);', input: 'background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: var(--radius-md);', badge: 'background: var(--accent-muted); color: var(--accent); padding: 0.25rem 0.5rem; border-radius: var(--radius-full);' } }, { headers: corsHeaders }) } } // Operaciones sobre un tema específico: /api/themes/:id if (url.pathname.startsWith('/api/themes/') && !url.pathname.includes('/active') && !url.pathname.includes('/export')) { const pathParts = url.pathname.split('/') const id = pathParts[3] const action = pathParts[4] // 'default' si existe // POST /api/themes/:id/default - Establecer como default if (action === 'default' && req.method === 'POST') { db.run('UPDATE themes SET is_default = 0') db.run('UPDATE themes SET is_default = 1 WHERE id = ?', [id]) return Response.json({ success: true }, { headers: corsHeaders }) } // PUT /api/themes/:id - Actualizar un tema existente if (req.method === 'PUT' && !action) { const theme = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any if (!theme) { return Response.json({ error: 'Theme not found' }, { status: 404, headers: corsHeaders }) } const body = await req.json() // Build update query dynamically based on provided fields const updates: string[] = [] const values: any[] = [] if (body.name !== undefined) { updates.push('name = ?') values.push(body.name) } if (body.description !== undefined) { updates.push('description = ?') values.push(body.description) } if (body.variables !== undefined) { updates.push('variables = ?') values.push(JSON.stringify(body.variables)) } if (body.metadata !== undefined) { updates.push('metadata = ?') values.push(JSON.stringify(body.metadata)) } if (updates.length > 0) { updates.push('updated_at = CURRENT_TIMESTAMP') values.push(id) const sql = `UPDATE themes SET ${updates.join(', ')} WHERE id = ?` db.run(sql, values) } return Response.json({ success: true, id }, { headers: corsHeaders }) } // GET /api/themes/:id - Obtener un tema if (req.method === 'GET' && !action) { const row = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any if (!row) { return Response.json({ error: 'Theme not found' }, { status: 404, headers: corsHeaders }) } return Response.json({ ...row, is_default: !!row.is_default, is_system: !!row.is_system, variables: JSON.parse(row.variables), metadata: row.metadata ? JSON.parse(row.metadata) : null }, { headers: corsHeaders }) } // DELETE /api/themes/:id - Eliminar un tema if (req.method === 'DELETE' && !action) { // No permitir eliminar temas del sistema const theme = db.query('SELECT is_system FROM themes WHERE id = ?').get(id) as { is_system: number } | null if (theme?.is_system) { return Response.json({ error: 'Cannot delete system theme' }, { status: 403, headers: corsHeaders }) } db.run('DELETE FROM themes WHERE id = ?', [id]) return Response.json({ success: true }, { headers: corsHeaders }) } } // GET /api/themes/export/:id - Exportar tema como JSON if (url.pathname.startsWith('/api/themes/export/')) { const id = url.pathname.split('/').pop() if (req.method === 'GET') { const row = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any if (!row) { return Response.json({ error: 'Theme not found' }, { status: 404, headers: corsHeaders }) } const exportData = { name: row.name, description: row.description, variables: JSON.parse(row.variables), metadata: { ...(row.metadata ? JSON.parse(row.metadata) : {}), exported_at: new Date().toISOString() } } return new Response(JSON.stringify(exportData, null, 2), { headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="${row.name.toLowerCase().replace(/\s+/g, '-')}-theme.json"` } }) } } // ===================== // API de Canvas // ===================== // GET /api/canvas/toolbar - Canvas para mostrar en toolbar if (url.pathname === '/api/canvas/toolbar') { if (req.method === 'GET') { const rows = db.query('SELECT * FROM project_canvas WHERE show_in_toolbar = 1 ORDER BY toolbar_order ASC, name ASC').all() const canvases = (rows as any[]).map(row => ({ ...row, is_default: !!row.is_default, is_system: !!row.is_system, show_in_toolbar: !!row.show_in_toolbar, config: row.config ? JSON.parse(row.config) : null, tools: row.tools ? JSON.parse(row.tools) : [] })) return Response.json(canvases, { headers: corsHeaders }) } } // GET /api/canvas/default - Canvas por defecto (para homepage) if (url.pathname === '/api/canvas/default') { if (req.method === 'GET') { const row = db.query('SELECT * FROM project_canvas WHERE is_default = 1 LIMIT 1').get() as any if (!row) { return Response.json({ hasDefault: false }, { headers: corsHeaders }) } return Response.json({ hasDefault: true, canvas: { ...row, is_default: !!row.is_default, is_system: !!row.is_system, show_in_toolbar: !!row.show_in_toolbar, config: row.config ? JSON.parse(row.config) : null, tools: row.tools ? JSON.parse(row.tools) : [] } }, { headers: corsHeaders }) } } // GET /api/canvas - Lista todos los canvas // POST /api/canvas - Crea un nuevo canvas if (url.pathname === '/api/canvas') { if (req.method === 'GET') { const rows = db.query('SELECT * FROM project_canvas ORDER BY is_system DESC, is_default DESC, name ASC').all() const canvases = (rows as any[]).map(row => ({ ...row, is_default: !!row.is_default, is_system: !!row.is_system, show_in_toolbar: !!row.show_in_toolbar, config: row.config ? JSON.parse(row.config) : null, tools: row.tools ? JSON.parse(row.tools) : [] })) return Response.json(canvases, { headers: corsHeaders }) } if (req.method === 'POST') { const body = await req.json() const id = body.id || `canvas-${Date.now()}` const stmt = db.prepare(` INSERT OR REPLACE INTO project_canvas (id, name, description, type, theme_id, config, tools, is_default, is_system, show_in_toolbar, toolbar_icon, toolbar_order, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `) stmt.run( id, body.name, body.description || '', body.type || 'project', body.theme_id || null, JSON.stringify(body.config || {}), JSON.stringify(body.tools || []), body.is_default ? 1 : 0, body.is_system ? 1 : 0, body.show_in_toolbar ? 1 : 0, body.toolbar_icon || null, body.toolbar_order ?? 99 ) return Response.json({ success: true, id }, { headers: corsHeaders }) } } // Operaciones sobre un canvas específico if (url.pathname.startsWith('/api/canvas/') && !url.pathname.includes('/components')) { const pathParts = url.pathname.split('/') const id = pathParts[3] const action = pathParts[4] // POST /api/canvas/:id/clone - Clonar canvas if (action === 'clone' && req.method === 'POST') { const original = db.query('SELECT * FROM project_canvas WHERE id = ?').get(id) as any if (!original) { return Response.json({ error: 'Canvas not found' }, { status: 404, headers: corsHeaders }) } const body = await req.json() const newId = `canvas-${Date.now()}` const newName = body.name || `${original.name} (copia)` // Clonar el canvas const stmt = db.prepare(` INSERT INTO project_canvas (id, name, description, type, theme_id, config, tools, is_default, is_system) VALUES (?, ?, ?, 'project', ?, ?, ?, 0, 0) `) stmt.run(newId, newName, original.description, original.theme_id, original.config, original.tools) // Clonar los componentes del canvas const components = db.query('SELECT * FROM canvas_components WHERE canvas_id = ?').all(id) as any[] if (components.length > 0) { const compStmt = db.prepare(` INSERT INTO canvas_components (canvas_id, component_id, position, props, layout, is_visible) VALUES (?, ?, ?, ?, ?, ?) `) for (const comp of components) { compStmt.run(newId, comp.component_id, comp.position, comp.props, comp.layout, comp.is_visible) } } return Response.json({ success: true, id: newId }, { headers: corsHeaders }) } // GET /api/canvas/:id - Obtener canvas if (req.method === 'GET' && !action) { const row = db.query('SELECT * FROM project_canvas WHERE id = ?').get(id) as any if (!row) { return Response.json({ error: 'Canvas not found' }, { status: 404, headers: corsHeaders }) } return Response.json({ ...row, is_default: !!row.is_default, is_system: !!row.is_system, show_in_toolbar: !!row.show_in_toolbar, config: row.config ? JSON.parse(row.config) : null, tools: row.tools ? JSON.parse(row.tools) : [] }, { headers: corsHeaders }) } // PUT /api/canvas/:id - Actualizar canvas if (req.method === 'PUT' && !action) { const canvas = db.query('SELECT * FROM project_canvas WHERE id = ?').get(id) as any if (!canvas) { return Response.json({ error: 'Canvas not found' }, { status: 404, headers: corsHeaders }) } const body = await req.json() const updates: string[] = [] const values: any[] = [] // System canvas solo puede modificar toolbar settings y is_default if (canvas.is_system) { if (body.is_default !== undefined) { updates.push('is_default = ?'); values.push(body.is_default ? 1 : 0) } if (body.show_in_toolbar !== undefined) { updates.push('show_in_toolbar = ?'); values.push(body.show_in_toolbar ? 1 : 0) } if (body.toolbar_icon !== undefined) { updates.push('toolbar_icon = ?'); values.push(body.toolbar_icon) } if (body.toolbar_order !== undefined) { updates.push('toolbar_order = ?'); values.push(body.toolbar_order) } } else { // Non-system canvas puede modificar todo if (body.name !== undefined) { updates.push('name = ?'); values.push(body.name) } if (body.description !== undefined) { updates.push('description = ?'); values.push(body.description) } if (body.theme_id !== undefined) { updates.push('theme_id = ?'); values.push(body.theme_id) } if (body.config !== undefined) { updates.push('config = ?'); values.push(JSON.stringify(body.config)) } if (body.tools !== undefined) { updates.push('tools = ?'); values.push(JSON.stringify(body.tools)) } if (body.is_default !== undefined) { updates.push('is_default = ?'); values.push(body.is_default ? 1 : 0) } if (body.show_in_toolbar !== undefined) { updates.push('show_in_toolbar = ?'); values.push(body.show_in_toolbar ? 1 : 0) } if (body.toolbar_icon !== undefined) { updates.push('toolbar_icon = ?'); values.push(body.toolbar_icon) } if (body.toolbar_order !== undefined) { updates.push('toolbar_order = ?'); values.push(body.toolbar_order) } } if (updates.length > 0) { updates.push('updated_at = CURRENT_TIMESTAMP') values.push(id) const sql = `UPDATE project_canvas SET ${updates.join(', ')} WHERE id = ?` db.run(sql, values) } return Response.json({ success: true, id }, { headers: corsHeaders }) } // DELETE /api/canvas/:id - Eliminar canvas if (req.method === 'DELETE' && !action) { const canvas = db.query('SELECT is_system FROM project_canvas WHERE id = ?').get(id) as { is_system: number } | null if (canvas?.is_system) { return Response.json({ error: 'Cannot delete system canvas' }, { status: 403, headers: corsHeaders }) } // canvas_components se eliminan automáticamente por CASCADE db.run('DELETE FROM project_canvas WHERE id = ?', [id]) return Response.json({ success: true }, { headers: corsHeaders }) } } // ===================== // API de Canvas Components // ===================== // GET /api/canvas/:id/components - Lista componentes del canvas // POST /api/canvas/:id/components - Agrega componente al canvas const canvasComponentsMatch = url.pathname.match(/^\/api\/canvas\/([^/]+)\/components\/?$/) if (canvasComponentsMatch) { const canvasId = canvasComponentsMatch[1] if (req.method === 'GET') { const rows = db.query(` SELECT cc.*, vc.name, vc.template, vc.setup, vc.style, vc.props as component_props, vc.imports FROM canvas_components cc JOIN vue_components vc ON cc.component_id = vc.id WHERE cc.canvas_id = ? ORDER BY cc.position ASC `).all(canvasId) as any[] const components = rows.map(row => ({ id: row.id, canvasId: row.canvas_id, componentId: row.component_id, position: row.position, props: row.props ? JSON.parse(row.props) : {}, layout: row.layout ? JSON.parse(row.layout) : null, isVisible: !!row.is_visible, createdAt: row.created_at, component: { id: row.component_id, name: row.name, template: row.template, setup: row.setup, style: row.style, props: row.component_props ? JSON.parse(row.component_props) : [], imports: row.imports ? JSON.parse(row.imports) : [] } })) return Response.json(components, { headers: corsHeaders }) } if (req.method === 'POST') { const body = await req.json() // Verificar que el componente existe const component = db.query('SELECT id FROM vue_components WHERE id = ?').get(body.component_id) if (!component) { return Response.json({ error: 'Component not found' }, { status: 404, headers: corsHeaders }) } // Obtener la siguiente posición const maxPos = db.query('SELECT MAX(position) as max FROM canvas_components WHERE canvas_id = ?').get(canvasId) as { max: number | null } const position = body.position ?? ((maxPos?.max ?? -1) + 1) const stmt = db.prepare(` INSERT OR REPLACE INTO canvas_components (canvas_id, component_id, position, props, layout, is_visible) VALUES (?, ?, ?, ?, ?, ?) `) stmt.run( canvasId, body.component_id, position, JSON.stringify(body.props || {}), body.layout ? JSON.stringify(body.layout) : null, body.is_visible !== false ? 1 : 0 ) return Response.json({ success: true }, { headers: corsHeaders }) } } // PUT/DELETE /api/canvas/:canvasId/components/:componentId const canvasComponentMatch = url.pathname.match(/^\/api\/canvas\/([^/]+)\/components\/([^/]+)$/) if (canvasComponentMatch) { const canvasId = canvasComponentMatch[1] const componentId = canvasComponentMatch[2] if (req.method === 'PUT') { const body = await req.json() const updates: string[] = [] const values: any[] = [] if (body.position !== undefined) { updates.push('position = ?'); values.push(body.position) } if (body.props !== undefined) { updates.push('props = ?'); values.push(JSON.stringify(body.props)) } if (body.layout !== undefined) { updates.push('layout = ?'); values.push(JSON.stringify(body.layout)) } if (body.is_visible !== undefined) { updates.push('is_visible = ?'); values.push(body.is_visible ? 1 : 0) } if (updates.length > 0) { values.push(canvasId, componentId) const sql = `UPDATE canvas_components SET ${updates.join(', ')} WHERE canvas_id = ? AND component_id = ?` db.run(sql, values) } return Response.json({ success: true }, { headers: corsHeaders }) } if (req.method === 'DELETE') { db.run('DELETE FROM canvas_components WHERE canvas_id = ? AND component_id = ?', [canvasId, componentId]) return Response.json({ success: true }, { headers: corsHeaders }) } } // GET /api/components/:id/usage - Canvas que usan el componente const componentUsageMatch = url.pathname.match(/^\/api\/components\/([^/]+)\/usage$/) if (componentUsageMatch && req.method === 'GET') { const componentId = componentUsageMatch[1] const usage = db.query(` SELECT pc.id, pc.name, pc.type FROM canvas_components cc JOIN project_canvas pc ON cc.canvas_id = pc.id WHERE cc.component_id = ? `).all(componentId) as { id: string; name: string; type: string }[] return Response.json({ componentId, usedBy: usage, canDelete: usage.length === 0 }, { headers: corsHeaders }) } // ===================== // API de Gitea (Source Code Viewer) // ===================== // POST /api/gitea/repo - Connect and get repo info if (url.pathname === '/api/gitea/repo' && req.method === 'POST') { const body = await req.json() const { giteaUrl, username, password, owner, repo } = body if (!giteaUrl || !username || !password || !owner || !repo) { return Response.json({ error: 'Missing required fields' }, { status: 400, headers: corsHeaders }) } try { const auth = Buffer.from(`${username}:${password}`).toString('base64') // Get repo info const repoRes = await fetch(`${giteaUrl}/api/v1/repos/${owner}/${repo}`, { headers: { 'Authorization': `Basic ${auth}` } }) if (!repoRes.ok) { if (repoRes.status === 401) { return Response.json({ error: 'Invalid credentials' }, { status: 401, headers: corsHeaders }) } if (repoRes.status === 404) { return Response.json({ error: 'Repository not found' }, { status: 404, headers: corsHeaders }) } throw new Error('Failed to connect to Gitea') } const repoData = await repoRes.json() // Get branches const branchesRes = await fetch(`${giteaUrl}/api/v1/repos/${owner}/${repo}/branches`, { headers: { 'Authorization': `Basic ${auth}` } }) let branches = ['main'] if (branchesRes.ok) { const branchesData = await branchesRes.json() branches = branchesData.map((b: any) => b.name) } return Response.json({ repo: { name: repoData.name, description: repoData.description, default_branch: repoData.default_branch, stars_count: repoData.stars_count, forks_count: repoData.forks_count, owner: { login: repoData.owner?.login || owner } }, branches }, { headers: corsHeaders }) } catch (e: any) { return Response.json({ error: e.message }, { status: 500, headers: corsHeaders }) } } // POST /api/gitea/tree - Get file tree if (url.pathname === '/api/gitea/tree' && req.method === 'POST') { const body = await req.json() const { giteaUrl, username, password, owner, repo, branch, path } = body try { const auth = Buffer.from(`${username}:${password}`).toString('base64') const apiPath = path ? `${giteaUrl}/api/v1/repos/${owner}/${repo}/contents/${path}?ref=${branch}` : `${giteaUrl}/api/v1/repos/${owner}/${repo}/contents?ref=${branch}` const res = await fetch(apiPath, { headers: { 'Authorization': `Basic ${auth}` } }) if (!res.ok) { throw new Error('Failed to load tree') } const data = await res.json() const items = Array.isArray(data) ? data : [data] const tree = items .map((item: any) => ({ name: item.name, path: item.path, type: item.type === 'dir' ? 'dir' : 'file', children: item.type === 'dir' ? [] : undefined })) .sort((a: any, b: any) => { // Folders first, then files if (a.type !== b.type) return a.type === 'dir' ? -1 : 1 return a.name.localeCompare(b.name) }) return Response.json({ tree }, { headers: corsHeaders }) } catch (e: any) { return Response.json({ error: e.message }, { status: 500, headers: corsHeaders }) } } // POST /api/gitea/file - Get file content if (url.pathname === '/api/gitea/file' && req.method === 'POST') { const body = await req.json() const { giteaUrl, username, password, owner, repo, branch, path } = body try { const auth = Buffer.from(`${username}:${password}`).toString('base64') const res = await fetch( `${giteaUrl}/api/v1/repos/${owner}/${repo}/contents/${path}?ref=${branch}`, { headers: { 'Authorization': `Basic ${auth}` } } ) if (!res.ok) { throw new Error('Failed to load file') } const data = await res.json() // Decode base64 content let content = '' if (data.content) { content = Buffer.from(data.content, 'base64').toString('utf-8') } return Response.json({ content, encoding: data.encoding, size: data.size, sha: data.sha }, { headers: corsHeaders }) } catch (e: any) { return Response.json({ error: e.message }, { status: 500, headers: corsHeaders }) } } // ===================== // API de Database Explorer // ===================== // GET /api/database/tables - Lista todas las tablas con conteo if (url.pathname === '/api/database/tables') { if (req.method === 'GET') { const tables = db.query(` SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name `).all() as { name: string }[] const result = tables.map(t => { const countResult = db.query(`SELECT COUNT(*) as count FROM "${t.name}"`).get() as { count: number } return { name: t.name, count: countResult.count } }) return Response.json(result, { headers: corsHeaders }) } } // GET /api/database/stats - Estadisticas de la BD if (url.pathname === '/api/database/stats') { if (req.method === 'GET') { // Get database file size const file = Bun.file('agent-ui.db') const size = file.size const sizeStr = size < 1024 ? `${size} B` : size < 1024 * 1024 ? `${(size / 1024).toFixed(1)} KB` : `${(size / (1024 * 1024)).toFixed(2)} MB` // Get tables and counts const tables = db.query(` SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' `).all() as { name: string }[] let totalRecords = 0 const breakdown = tables.map(t => { const countResult = db.query(`SELECT COUNT(*) as count FROM "${t.name}"`).get() as { count: number } totalRecords += countResult.count return { name: t.name, count: countResult.count } }) return Response.json({ size: sizeStr, tables: tables.length, totalRecords, breakdown }, { headers: corsHeaders }) } } // GET /api/database/tables/:name/schema - Esquema de una tabla const tableSchemaMatch = url.pathname.match(/^\/api\/database\/tables\/([^/]+)\/schema$/) if (tableSchemaMatch && req.method === 'GET') { const tableName = decodeURIComponent(tableSchemaMatch[1]) // Verify table exists const tableExists = db.query(` SELECT name FROM sqlite_master WHERE type='table' AND name = ? `).get(tableName) if (!tableExists) { return Response.json({ error: 'Table not found' }, { status: 404, headers: corsHeaders }) } const schema = db.query(`PRAGMA table_info("${tableName}")`).all() as any[] const result = schema.map(col => ({ name: col.name, type: col.type, notnull: !!col.notnull, pk: !!col.pk })) return Response.json(result, { headers: corsHeaders }) } // GET /api/database/tables/:name/data - Datos de una tabla con paginacion const tableDataMatch = url.pathname.match(/^\/api\/database\/tables\/([^/]+)\/data$/) if (tableDataMatch && req.method === 'GET') { const tableName = decodeURIComponent(tableDataMatch[1]) // Verify table exists const tableExists = db.query(` SELECT name FROM sqlite_master WHERE type='table' AND name = ? `).get(tableName) if (!tableExists) { return Response.json({ error: 'Table not found' }, { status: 404, headers: corsHeaders }) } const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 500) const offset = parseInt(url.searchParams.get('offset') || '0') const countResult = db.query(`SELECT COUNT(*) as count FROM "${tableName}"`).get() as { count: number } const rows = db.query(`SELECT * FROM "${tableName}" LIMIT ? OFFSET ?`).all(limit, offset) return Response.json({ total: countResult.count, limit, offset, rows }, { headers: corsHeaders }) } // POST /api/database/query - Ejecutar consulta SELECT if (url.pathname === '/api/database/query') { if (req.method === 'POST') { const body = await req.json() const query = (body.query || '').trim() // Security: Only allow SELECT statements const normalizedQuery = query.toLowerCase() if (!normalizedQuery.startsWith('select')) { return Response.json({ error: 'Only SELECT queries are allowed for security reasons' }, { status: 403, headers: corsHeaders }) } // Block dangerous keywords const dangerousKeywords = ['drop', 'delete', 'update', 'insert', 'alter', 'create', 'truncate', 'replace'] for (const keyword of dangerousKeywords) { if (normalizedQuery.includes(keyword)) { return Response.json({ error: `Query contains forbidden keyword: ${keyword.toUpperCase()}` }, { status: 403, headers: corsHeaders }) } } try { const rows = db.query(query).all() return Response.json({ rows }, { headers: corsHeaders }) } catch (e: any) { return Response.json({ error: e.message }, { status: 400, headers: corsHeaders }) } } } return new Response('Not Found', { status: 404, headers: corsHeaders }) } }) console.log(`[HTTP] API corriendo en http://localhost:${PORT_HTTP}`) // ===================== // Terminal WebSocket Server // ===================== const WORKING_DIR = process.cwd().replace(/[\\\/]server$/, '') // Go to project root const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash' const shellArgs = process.platform === 'win32' ? ['-NoLogo', '-NoProfile'] : [] // Store active terminal sessions by ID (persisten entre reconexiones) const sessions = new Map() const DEFAULT_SESSION_ID = 'main' const MAX_BUFFER_LINES = 1000 // Helper: obtener o crear sesión function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSession { let session = sessions.get(sessionId) if (!session) { console.log(`[Terminal] Creating new session: ${sessionId}`) const pty = spawn(shell, shellArgs, { name: 'xterm-256color', cols: 80, rows: 24, cwd: WORKING_DIR }) session = { id: sessionId, pty, outputBuffer: [], maxBufferSize: MAX_BUFFER_LINES, clients: new Set(), createdAt: new Date() } // Capturar output en buffer y enviar a clientes pty.onData((data: string) => { // Guardar en buffer (para replay) session!.outputBuffer.push(data) if (session!.outputBuffer.length > session!.maxBufferSize) { session!.outputBuffer.shift() } // Enviar a todos los clientes conectados for (const ws of session!.clients) { try { ws.send(JSON.stringify({ type: 'output', data })) } catch (e) { // Cliente desconectado } } }) // Handle PTY exit pty.onExit(({ exitCode, signal }) => { console.log(`[Terminal] Session ${sessionId} exited with code ${exitCode}`) for (const ws of session!.clients) { try { ws.send(JSON.stringify({ type: 'exit', data: `\r\n\x1b[33mSession ended (code ${exitCode})\x1b[0m\r\n` })) } catch (e) {} } sessions.delete(sessionId) }) sessions.set(sessionId, session) console.log(`[Terminal] Session ${sessionId} created, PID: ${pty.pid}`) } return session } // Mapa de WebSocket a sessionId const wsToSession = new Map() const terminalServer = Bun.serve({ port: PORT_TERMINAL, fetch(req, server) { const url = new URL(req.url) // Health check con info de sesiones if (url.pathname === '/health') { const sessionsInfo = Array.from(sessions.entries()).map(([id, s]) => ({ id, clients: s.clients.size, pid: s.pty.pid, bufferSize: s.outputBuffer.length, createdAt: s.createdAt.toISOString() })) return Response.json({ status: 'ok', sessions: sessionsInfo, cwd: WORKING_DIR }) } // Listar sesiones activas if (url.pathname === '/sessions') { const list = Array.from(sessions.keys()) return Response.json({ sessions: list }) } // Check if this is a WebSocket upgrade request const upgradeHeader = req.headers.get('upgrade') console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`) if (upgradeHeader?.toLowerCase() === 'websocket') { // Obtener sessionId del query param o usar default const sessionId = url.searchParams.get('session') || DEFAULT_SESSION_ID const success = server.upgrade(req, { data: { sessionId } }) console.log(`[Terminal] WebSocket upgrade for session "${sessionId}": ${success ? 'success' : 'failed'}`) if (success) { return undefined } return new Response('WebSocket upgrade failed', { status: 400 }) } return new Response('Terminal WebSocket Server - Persistent Sessions\n\nEndpoints:\n /health - Server status\n /sessions - List active sessions\n ws://...?session= - Connect to session', { status: 200 }) }, websocket: { open(ws) { const sessionId = (ws.data as any)?.sessionId || DEFAULT_SESSION_ID console.log(`[Terminal] Client connecting to session: ${sessionId}`) try { const session = getOrCreateSession(sessionId) session.clients.add(ws) wsToSession.set(ws, sessionId) // Enviar info de conexión ws.send(JSON.stringify({ type: 'connected', sessionId: session.id, isNew: session.outputBuffer.length === 0 })) // Replay del buffer si hay historial if (session.outputBuffer.length > 0) { console.log(`[Terminal] Replaying ${session.outputBuffer.length} buffer entries`) ws.send(JSON.stringify({ type: 'replay', data: session.outputBuffer.join('') })) } console.log(`[Terminal] Client joined session ${sessionId} (${session.clients.size} clients)`) } catch (e: any) { console.error('[Terminal] Error:', e) ws.send(JSON.stringify({ type: 'error', message: e.message })) } }, message(ws, message) { try { const msg = JSON.parse(message as string) const sessionId = wsToSession.get(ws) if (!sessionId) return const session = sessions.get(sessionId) if (!session) return if (msg.type === 'input') { session.pty.write(msg.data) } else if (msg.type === 'resize' && msg.cols && msg.rows) { session.pty.resize(msg.cols, msg.rows) console.log(`[Terminal] Session ${sessionId} resized to ${msg.cols}x${msg.rows}`) } } catch (e: any) { console.error('[Terminal] Error:', e) } }, close(ws) { const sessionId = wsToSession.get(ws) if (sessionId) { const session = sessions.get(sessionId) if (session) { session.clients.delete(ws) console.log(`[Terminal] Client left session ${sessionId} (${session.clients.size} clients remaining)`) // NO matamos el PTY - la sesión persiste } wsToSession.delete(ws) } } } }) console.log(`[Terminal] WebSocket corriendo en ws://localhost:${PORT_TERMINAL}`) console.log('') console.log('='.repeat(50)) console.log('Agent UI Server iniciado') console.log(` API: http://localhost:${PORT_HTTP}`) console.log(` Terminal: ws://localhost:${PORT_TERMINAL}`) console.log(` Working Dir: ${WORKING_DIR}`) console.log('') console.log('WebMCP se inicia por separado con Claude Code MCP') console.log('='.repeat(50))