diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3afb748..210190e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,7 +10,7 @@ import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './servi const route = useRoute() const router = useRouter() -type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' +type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' onMounted(async () => { // Initialize WebMCP connection diff --git a/frontend/src/components/Toolbar.vue b/frontend/src/components/Toolbar.vue index a8dc22b..7f214ef 100644 --- a/frontend/src/components/Toolbar.vue +++ b/frontend/src/components/Toolbar.vue @@ -95,6 +95,14 @@ onMounted(() => { + + + + + + + +
diff --git a/frontend/src/pages/DatabasePage.vue b/frontend/src/pages/DatabasePage.vue new file mode 100644 index 0000000..5d25b7f --- /dev/null +++ b/frontend/src/pages/DatabasePage.vue @@ -0,0 +1,959 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3c4c960..aa0b08c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -33,6 +33,11 @@ const router = createRouter({ path: '/themes', name: 'themes', component: () => import('../pages/ThemesPage.vue') + }, + { + path: '/database', + name: 'database', + component: () => import('../pages/DatabasePage.vue') } ] }) diff --git a/frontend/src/services/toolRegistry.ts b/frontend/src/services/toolRegistry.ts index 03a703e..592309f 100644 --- a/frontend/src/services/toolRegistry.ts +++ b/frontend/src/services/toolRegistry.ts @@ -24,8 +24,13 @@ import { unregisterProjectCanvasTools, PROJECT_CANVAS_TOOLS } from './tools/projectCanvasTools' +import { + registerDatabaseTools, + unregisterDatabaseTools, + DATABASE_TOOLS +} from './tools/databaseTools' -type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' +type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' interface PageToolSet { register: () => void @@ -85,6 +90,11 @@ const pageTools: Record = { register: registerThemeTools, unregister: unregisterThemeTools, toolNames: THEME_TOOLS + }, + database: { + register: registerDatabaseTools, + unregister: unregisterDatabaseTools, + toolNames: DATABASE_TOOLS } } diff --git a/frontend/src/services/tools/databaseTools.ts b/frontend/src/services/tools/databaseTools.ts new file mode 100644 index 0000000..6e29228 --- /dev/null +++ b/frontend/src/services/tools/databaseTools.ts @@ -0,0 +1,231 @@ +import { registerTool, unregisterTools } from '../webmcp' + +export const DATABASE_TOOLS = [ + 'list_tables', + 'get_table_schema', + 'get_table_data', + 'get_database_stats', + 'execute_query' +] + +const API_BASE = 'http://localhost:4101' + +export function registerDatabaseTools() { + // list_tables + registerTool( + 'list_tables', + 'Lista todas las tablas de la base de datos SQLite con su conteo de registros', + { + type: 'object', + properties: {} + }, + async () => { + try { + const res = await fetch(`${API_BASE}/api/database/tables`) + if (!res.ok) throw new Error('Failed to fetch tables') + const tables = await res.json() + + if (tables.length === 0) { + return 'No hay tablas en la base de datos' + } + + const tableList = tables.map((t: any) => ` - ${t.name}: ${t.count} registros`).join('\n') + const total = tables.reduce((sum: number, t: any) => sum + t.count, 0) + + return `Tablas en la base de datos (${tables.length}):\n\n${tableList}\n\nTotal de registros: ${total}` + } catch (e: any) { + return `Error: ${e.message}` + } + } + ) + + // get_table_schema + registerTool( + 'get_table_schema', + 'Obtiene el esquema (columnas y tipos) de una tabla', + { + type: 'object', + properties: { + table: { + type: 'string', + description: 'Nombre de la tabla' + } + }, + required: ['table'] + }, + async (args: { table: string }) => { + try { + const res = await fetch(`${API_BASE}/api/database/tables/${args.table}/schema`) + if (!res.ok) { + if (res.status === 404) return `Tabla "${args.table}" no encontrada` + throw new Error('Failed to fetch schema') + } + const schema = await res.json() + + if (schema.length === 0) { + return `La tabla "${args.table}" no tiene columnas definidas` + } + + const columns = schema.map((col: any) => { + const flags = [] + if (col.pk) flags.push('PRIMARY KEY') + if (col.notnull) flags.push('NOT NULL') + const flagStr = flags.length > 0 ? ` (${flags.join(', ')})` : '' + return ` - ${col.name}: ${col.type}${flagStr}` + }).join('\n') + + return `Esquema de la tabla "${args.table}":\n\n${columns}` + } catch (e: any) { + return `Error: ${e.message}` + } + } + ) + + // get_table_data + registerTool( + 'get_table_data', + 'Obtiene los datos de una tabla con paginacion', + { + type: 'object', + properties: { + table: { + type: 'string', + description: 'Nombre de la tabla' + }, + limit: { + type: 'number', + description: 'Numero de registros a retornar (default: 20, max: 100)' + }, + offset: { + type: 'number', + description: 'Registros a saltar para paginacion (default: 0)' + } + }, + required: ['table'] + }, + async (args: { table: string; limit?: number; offset?: number }) => { + try { + const limit = Math.min(args.limit || 20, 100) + const offset = args.offset || 0 + + const res = await fetch( + `${API_BASE}/api/database/tables/${args.table}/data?limit=${limit}&offset=${offset}` + ) + if (!res.ok) { + if (res.status === 404) return `Tabla "${args.table}" no encontrada` + throw new Error('Failed to fetch data') + } + const result = await res.json() + + if (result.rows.length === 0) { + return `La tabla "${args.table}" no tiene registros` + } + + // Format as readable table + const rows = result.rows.map((row: any, idx: number) => { + const entries = Object.entries(row).map(([k, v]) => { + let value = v + if (typeof v === 'string' && v.length > 50) { + value = v.substring(0, 50) + '...' + } else if (typeof v === 'object') { + value = JSON.stringify(v).substring(0, 50) + '...' + } + return `${k}: ${value}` + }).join(', ') + return `[${offset + idx + 1}] ${entries}` + }).join('\n') + + return `Datos de "${args.table}" (${offset + 1}-${offset + result.rows.length} de ${result.total}):\n\n${rows}\n\nUsa offset=${offset + limit} para ver mas registros` + } catch (e: any) { + return `Error: ${e.message}` + } + } + ) + + // get_database_stats + registerTool( + 'get_database_stats', + 'Obtiene estadisticas generales de la base de datos', + { + type: 'object', + properties: {} + }, + async () => { + try { + const res = await fetch(`${API_BASE}/api/database/stats`) + if (!res.ok) throw new Error('Failed to fetch stats') + const stats = await res.json() + + return `Estadisticas de la base de datos:\n\n` + + ` Tamano: ${stats.size}\n` + + ` Tablas: ${stats.tables}\n` + + ` Registros totales: ${stats.totalRecords}\n\n` + + `Desglose por tabla:\n` + + stats.breakdown.map((t: any) => ` - ${t.name}: ${t.count} registros`).join('\n') + } catch (e: any) { + return `Error: ${e.message}` + } + } + ) + + // execute_query + registerTool( + 'execute_query', + 'Ejecuta una consulta SQL SELECT en la base de datos (solo lectura)', + { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Consulta SQL (solo SELECT permitido)' + } + }, + required: ['query'] + }, + async (args: { query: string }) => { + try { + const res = await fetch(`${API_BASE}/api/database/query`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: args.query }) + }) + + const result = await res.json() + + if (!res.ok) { + return `Error en la consulta: ${result.error}` + } + + if (result.rows.length === 0) { + return 'La consulta no retorno resultados' + } + + // Format results + const columns = Object.keys(result.rows[0]) + const header = columns.join(' | ') + const separator = columns.map(() => '---').join(' | ') + const rows = result.rows.slice(0, 50).map((row: any) => { + return columns.map(col => { + let value = row[col] + if (value === null) return 'NULL' + if (typeof value === 'object') return JSON.stringify(value) + if (typeof value === 'string' && value.length > 40) { + return value.substring(0, 40) + '...' + } + return String(value) + }).join(' | ') + }).join('\n') + + const truncated = result.rows.length > 50 ? `\n\n... y ${result.rows.length - 50} filas mas` : '' + + return `Resultados (${result.rows.length} filas):\n\n${header}\n${separator}\n${rows}${truncated}` + } catch (e: any) { + return `Error: ${e.message}` + } + } + ) +} + +export function unregisterDatabaseTools() { + unregisterTools(DATABASE_TOOLS) +} diff --git a/server/index.ts b/server/index.ts index a1f3379..b6e1a21 100644 --- a/server/index.ts +++ b/server/index.ts @@ -861,6 +861,148 @@ Bun.serve({ }, { 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 }) } })