Files
agent-ui/server/index.ts
josedario87 97ef49aea4 feat: Add database explorer page with SQLite management
- Add /database page with table explorer, query executor and stats
- Implement MCP tools: list_tables, get_table_schema, get_table_data,
  get_database_stats, execute_query
- Add database API endpoints with security (SELECT only)
- Add database icon to toolbar
2026-02-13 06:43:52 -06:00

1018 lines
38 KiB
TypeScript

import { Database } from 'bun:sqlite'
const PORT_HTTP = 4101
// 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 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}`)
console.log('')
console.log('='.repeat(50))
console.log('Agent UI API Server iniciado')
console.log(` API: http://localhost:${PORT_HTTP}`)
console.log('')
console.log('WebMCP se inicia por separado con Claude Code MCP')
console.log('='.repeat(50))