From 645f51a74ef21d5f5c9856adc930ca04fa137a5a Mon Sep 17 00:00:00 2001 From: josedario87 Date: Fri, 13 Feb 2026 13:01:18 -0600 Subject: [PATCH] refactor: Modularize server into separate concerns Split monolithic index.ts (~1400 lines) into modular structure: - config.ts: Server configuration and constants - db/: Database initialization, migrations, and seeds - routes/: API handlers by domain (themes, canvas, components, etc.) - services/: Terminal WebSocket server - utils/: CORS helpers Entry point now only coordinates initialization. --- server/config.ts | 13 + server/db/index.ts | 16 + server/db/migrations.ts | 107 +++ server/db/seeds.ts | 134 ++++ server/index.ts | 1397 +---------------------------------- server/routes/canvas.ts | 250 +++++++ server/routes/components.ts | 84 +++ server/routes/config.ts | 27 + server/routes/database.ts | 119 +++ server/routes/gitea.ts | 130 ++++ server/routes/history.ts | 24 + server/routes/index.ts | 172 +++++ server/routes/themes.ts | 157 ++++ server/routes/webmcp.ts | 42 ++ server/services/terminal.ts | 192 +++++ server/utils/cors.ts | 21 + 16 files changed, 1503 insertions(+), 1382 deletions(-) create mode 100644 server/config.ts create mode 100644 server/db/index.ts create mode 100644 server/db/migrations.ts create mode 100644 server/db/seeds.ts create mode 100644 server/routes/canvas.ts create mode 100644 server/routes/components.ts create mode 100644 server/routes/config.ts create mode 100644 server/routes/database.ts create mode 100644 server/routes/gitea.ts create mode 100644 server/routes/history.ts create mode 100644 server/routes/index.ts create mode 100644 server/routes/themes.ts create mode 100644 server/routes/webmcp.ts create mode 100644 server/services/terminal.ts create mode 100644 server/utils/cors.ts diff --git a/server/config.ts b/server/config.ts new file mode 100644 index 0000000..2611c42 --- /dev/null +++ b/server/config.ts @@ -0,0 +1,13 @@ +// Server configuration +export const PORT_HTTP = 4101 +export const PORT_TERMINAL = 4103 + +// Terminal configuration +export const WORKING_DIR = process.cwd().replace(/[\\\/]server$/, '') +export const SHELL = process.platform === 'win32' ? 'powershell.exe' : 'bash' +export const SHELL_ARGS = process.platform === 'win32' ? ['-NoLogo', '-NoProfile'] : [] +export const DEFAULT_SESSION_ID = 'main' +export const MAX_BUFFER_LINES = 1000 + +// Database +export const DB_PATH = 'agent-ui.db' diff --git a/server/db/index.ts b/server/db/index.ts new file mode 100644 index 0000000..ce63d86 --- /dev/null +++ b/server/db/index.ts @@ -0,0 +1,16 @@ +import { Database } from 'bun:sqlite' +import { DB_PATH } from '../config' +import { runMigrations } from './migrations' +import { runSeeds } from './seeds' + +// Create database instance +export const db = new Database(DB_PATH) + +// Initialize database +export function initDatabase() { + runMigrations(db) + runSeeds(db) + console.log('[DB] SQLite initialized:', DB_PATH) +} + +export { Database } diff --git a/server/db/migrations.ts b/server/db/migrations.ts new file mode 100644 index 0000000..dee9f50 --- /dev/null +++ b/server/db/migrations.ts @@ -0,0 +1,107 @@ +import type { Database } from 'bun:sqlite' + +export function runMigrations(db: Database) { + // History table + 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 + ) + `) + + // Config table + db.run(` + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT + ) + `) + + // Vue components table + 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 + ) + `) + + // Themes table + 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 + ) + `) + + // Project canvas table + 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 + ) + `) + + // Canvas-components relation table + 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) + ) + `) + + // Run column migrations for existing tables + runColumnMigrations(db) +} + +function runColumnMigrations(db: Database) { + // Add toolbar columns to project_canvas if missing + const alterStatements = [ + 'ALTER TABLE project_canvas ADD COLUMN show_in_toolbar INTEGER DEFAULT 0', + 'ALTER TABLE project_canvas ADD COLUMN toolbar_icon TEXT', + 'ALTER TABLE project_canvas ADD COLUMN toolbar_order INTEGER DEFAULT 99' + ] + + for (const sql of alterStatements) { + try { + db.run(sql) + } catch { + // Column already exists + } + } +} diff --git a/server/db/seeds.ts b/server/db/seeds.ts new file mode 100644 index 0000000..e69e6c7 --- /dev/null +++ b/server/db/seeds.ts @@ -0,0 +1,134 @@ +import type { Database } from 'bun:sqlite' + +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'] }) +} + +export function runSeeds(db: Database) { + // Check if system themes exist + const existingThemes = db.query('SELECT COUNT(*) as count FROM themes WHERE is_system = 1').get() as { count: number } + + if (existingThemes.count === 0) { + 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] System themes created') + } +} diff --git a/server/index.ts b/server/index.ts index 85aa067..a7e9d81 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,1396 +1,29 @@ -import { Database } from 'bun:sqlite' -import { spawn, type IPty } from '@skitee3000/bun-pty' +import { PORT_HTTP, WORKING_DIR } from './config' +import { initDatabase } from './db' +import { handleRequest } from './routes' +import { startTerminalServer } from './services/terminal' -const PORT_HTTP = 4101 -const PORT_TERMINAL = 4103 +// Initialize database +initDatabase() -// WebMCP token storage (in-memory, for passing token to browser) -let pendingWebMCPToken: { token: string; createdAt: Date } | null = null - -// 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 +// Start HTTP API server 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 }) - } - - // WebMCP Token API - para pasar token al browser - if (url.pathname === '/api/webmcp-token') { - if (req.method === 'GET') { - if (pendingWebMCPToken) { - // Check if token is not expired (5 minutes) - const age = Date.now() - pendingWebMCPToken.createdAt.getTime() - if (age < 5 * 60 * 1000) { - return Response.json({ - token: pendingWebMCPToken.token, - createdAt: pendingWebMCPToken.createdAt.toISOString() - }, { headers: corsHeaders }) - } - // Token expired - pendingWebMCPToken = null - } - return Response.json({ token: null }, { headers: corsHeaders }) - } - - if (req.method === 'POST') { - const body = await req.json() - if (body.token) { - pendingWebMCPToken = { - token: body.token, - createdAt: new Date() - } - console.log('[WebMCP] Token received and stored') - return Response.json({ success: true }, { headers: corsHeaders }) - } - return Response.json({ error: 'Token required' }, { status: 400, headers: corsHeaders }) - } - - if (req.method === 'DELETE') { - pendingWebMCPToken = null - return Response.json({ success: true }, { 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 }) - } + fetch: handleRequest }) -console.log(`[HTTP] API corriendo en http://localhost:${PORT_HTTP}`) +console.log(`[HTTP] API running at http://localhost:${PORT_HTTP}`) -// ===================== -// Terminal WebSocket Server -// ===================== +// Start Terminal WebSocket server +startTerminalServer() -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}`) +// Startup summary console.log('') console.log('='.repeat(50)) -console.log('Agent UI Server iniciado') +console.log('Agent UI Server started') console.log(` API: http://localhost:${PORT_HTTP}`) -console.log(` Terminal: ws://localhost:${PORT_TERMINAL}`) +console.log(` Terminal: ws://localhost:4103`) console.log(` Working Dir: ${WORKING_DIR}`) console.log('') -console.log('WebMCP se inicia por separado con Claude Code MCP') +console.log('WebMCP starts separately with Claude Code MCP') console.log('='.repeat(50)) diff --git a/server/routes/canvas.ts b/server/routes/canvas.ts new file mode 100644 index 0000000..77916bf --- /dev/null +++ b/server/routes/canvas.ts @@ -0,0 +1,250 @@ +import { db } from '../db' +import { jsonResponse, errorResponse } from '../utils/cors' + +function parseCanvas(row: any) { + return { + ...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) : [] + } +} + +export function handleToolbarCanvas() { + const rows = db.query('SELECT * FROM project_canvas WHERE show_in_toolbar = 1 ORDER BY toolbar_order ASC, name ASC').all() + return jsonResponse((rows as any[]).map(parseCanvas)) +} + +export function handleDefaultCanvas() { + const row = db.query('SELECT * FROM project_canvas WHERE is_default = 1 LIMIT 1').get() as any + if (!row) { + return jsonResponse({ hasDefault: false }) + } + return jsonResponse({ hasDefault: true, canvas: parseCanvas(row) }) +} + +export async function handleCanvas(req: Request) { + if (req.method === 'GET') { + const rows = db.query('SELECT * FROM project_canvas ORDER BY is_system DESC, is_default DESC, name ASC').all() + return jsonResponse((rows as any[]).map(parseCanvas)) + } + + 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 jsonResponse({ success: true, id }) + } + + return null +} + +export async function handleCanvasById(req: Request, id: string, action?: string) { + // POST /api/canvas/:id/clone + if (action === 'clone' && req.method === 'POST') { + const original = db.query('SELECT * FROM project_canvas WHERE id = ?').get(id) as any + if (!original) { + return errorResponse('Canvas not found', 404) + } + + const body = await req.json() + const newId = `canvas-${Date.now()}` + const newName = body.name || `${original.name} (copia)` + + 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) + + // Clone canvas components + 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 jsonResponse({ success: true, id: newId }) + } + + // GET /api/canvas/:id + if (req.method === 'GET' && !action) { + const row = db.query('SELECT * FROM project_canvas WHERE id = ?').get(id) as any + if (!row) { + return errorResponse('Canvas not found', 404) + } + return jsonResponse(parseCanvas(row)) + } + + // PUT /api/canvas/:id + if (req.method === 'PUT' && !action) { + const canvas = db.query('SELECT * FROM project_canvas WHERE id = ?').get(id) as any + if (!canvas) { + return errorResponse('Canvas not found', 404) + } + + const body = await req.json() + const updates: string[] = [] + const values: any[] = [] + + // System canvas can only modify toolbar settings and 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 { + 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 jsonResponse({ success: true, id }) + } + + // DELETE /api/canvas/:id + 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 errorResponse('Cannot delete system canvas', 403) + } + db.run('DELETE FROM project_canvas WHERE id = ?', [id]) + return jsonResponse({ success: true }) + } + + return null +} + +// Canvas Components API +export async function handleCanvasComponents(req: Request, canvasId: string) { + 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 jsonResponse(components) + } + + if (req.method === 'POST') { + const body = await req.json() + + // Verify component exists + const component = db.query('SELECT id FROM vue_components WHERE id = ?').get(body.component_id) + if (!component) { + return errorResponse('Component not found', 404) + } + + // Get next position + 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 jsonResponse({ success: true }) + } + + return null +} + +export async function handleCanvasComponentById(req: Request, canvasId: string, componentId: string) { + 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 jsonResponse({ success: true }) + } + + if (req.method === 'DELETE') { + db.run('DELETE FROM canvas_components WHERE canvas_id = ? AND component_id = ?', [canvasId, componentId]) + return jsonResponse({ success: true }) + } + + return null +} diff --git a/server/routes/components.ts b/server/routes/components.ts new file mode 100644 index 0000000..b4b90d9 --- /dev/null +++ b/server/routes/components.ts @@ -0,0 +1,84 @@ +import { db } from '../db' +import { jsonResponse, errorResponse } from '../utils/cors' + +export async function handleComponents(req: Request) { + if (req.method === 'GET') { + const rows = db.query('SELECT * FROM vue_components ORDER BY updated_at DESC').all() + return jsonResponse(rows) + } + + 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 jsonResponse({ success: true, id }) + } + + if (req.method === 'DELETE') { + db.run('DELETE FROM vue_components') + return jsonResponse({ success: true }) + } + + return null +} + +export async function handleComponentById(req: Request, id: string) { + if (req.method === 'GET') { + const row = db.query('SELECT * FROM vue_components WHERE id = ?').get(id) + if (!row) { + return errorResponse('Component not found', 404) + } + return jsonResponse(row) + } + + if (req.method === 'DELETE') { + // Check if component is in use by any 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 jsonResponse({ + error: 'Component in use', + message: `Cannot delete component. It is used by: ${usage.map(u => u.name).join(', ')}`, + usedBy: usage + }, 409) + } + + db.run('DELETE FROM vue_components WHERE id = ?', [id]) + return jsonResponse({ success: true }) + } + + return null +} + +export function handleComponentUsage(componentId: string) { + 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 jsonResponse({ + componentId, + usedBy: usage, + canDelete: usage.length === 0 + }) +} diff --git a/server/routes/config.ts b/server/routes/config.ts new file mode 100644 index 0000000..bdb411d --- /dev/null +++ b/server/routes/config.ts @@ -0,0 +1,27 @@ +import { db } from '../db' +import { jsonResponse } from '../utils/cors' + +export async function handleConfig(req: Request, url: URL) { + 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 jsonResponse({ value: row?.value || null }) + } + const rows = db.query('SELECT * FROM config').all() + return jsonResponse(rows) + } + + 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 jsonResponse({ success: true }) + } + + return null +} + +export function handleHealth() { + return jsonResponse({ status: 'ok', timestamp: new Date().toISOString() }) +} diff --git a/server/routes/database.ts b/server/routes/database.ts new file mode 100644 index 0000000..fd291e0 --- /dev/null +++ b/server/routes/database.ts @@ -0,0 +1,119 @@ +import { db } from '../db' +import { jsonResponse, errorResponse } from '../utils/cors' + +export function handleTables() { + 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 jsonResponse(result) +} + +export async function handleStats() { + // 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 jsonResponse({ + size: sizeStr, + tables: tables.length, + totalRecords, + breakdown + }) +} + +export function handleTableSchema(tableName: string) { + // Verify table exists + const tableExists = db.query(` + SELECT name FROM sqlite_master + WHERE type='table' AND name = ? + `).get(tableName) + + if (!tableExists) { + return errorResponse('Table not found', 404) + } + + 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 jsonResponse(result) +} + +export function handleTableData(tableName: string, url: URL) { + // Verify table exists + const tableExists = db.query(` + SELECT name FROM sqlite_master + WHERE type='table' AND name = ? + `).get(tableName) + + if (!tableExists) { + return errorResponse('Table not found', 404) + } + + 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 jsonResponse({ + total: countResult.count, + limit, + offset, + rows + }) +} + +export async function handleQuery(req: Request) { + const body = await req.json() + const query = (body.query || '').trim() + + // Security: Only allow SELECT statements + const normalizedQuery = query.toLowerCase() + if (!normalizedQuery.startsWith('select')) { + return errorResponse('Only SELECT queries are allowed for security reasons', 403) + } + + // Block dangerous keywords + const dangerousKeywords = ['drop', 'delete', 'update', 'insert', 'alter', 'create', 'truncate', 'replace'] + for (const keyword of dangerousKeywords) { + if (normalizedQuery.includes(keyword)) { + return errorResponse(`Query contains forbidden keyword: ${keyword.toUpperCase()}`, 403) + } + } + + try { + const rows = db.query(query).all() + return jsonResponse({ rows }) + } catch (e: any) { + return errorResponse(e.message, 400) + } +} diff --git a/server/routes/gitea.ts b/server/routes/gitea.ts new file mode 100644 index 0000000..a9d97c2 --- /dev/null +++ b/server/routes/gitea.ts @@ -0,0 +1,130 @@ +import { jsonResponse, errorResponse } from '../utils/cors' + +export async function handleGiteaRepo(req: Request) { + const body = await req.json() + const { giteaUrl, username, password, owner, repo } = body + + if (!giteaUrl || !username || !password || !owner || !repo) { + return errorResponse('Missing required fields', 400) + } + + 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 errorResponse('Invalid credentials', 401) + } + if (repoRes.status === 404) { + return errorResponse('Repository not found', 404) + } + 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 jsonResponse({ + 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 + }) + } catch (e: any) { + return errorResponse(e.message, 500) + } +} + +export async function handleGiteaTree(req: Request) { + 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) => { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1 + return a.name.localeCompare(b.name) + }) + + return jsonResponse({ tree }) + } catch (e: any) { + return errorResponse(e.message, 500) + } +} + +export async function handleGiteaFile(req: Request) { + 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 jsonResponse({ + content, + encoding: data.encoding, + size: data.size, + sha: data.sha + }) + } catch (e: any) { + return errorResponse(e.message, 500) + } +} diff --git a/server/routes/history.ts b/server/routes/history.ts new file mode 100644 index 0000000..4ad290c --- /dev/null +++ b/server/routes/history.ts @@ -0,0 +1,24 @@ +import { db } from '../db' +import { jsonResponse } from '../utils/cors' + +export async function handleHistory(req: Request, url: URL) { + 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 jsonResponse(rows) + } + + 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 jsonResponse({ success: true }) + } + + if (req.method === 'DELETE') { + db.run('DELETE FROM history') + return jsonResponse({ success: true }) + } + + return null +} diff --git a/server/routes/index.ts b/server/routes/index.ts new file mode 100644 index 0000000..749d410 --- /dev/null +++ b/server/routes/index.ts @@ -0,0 +1,172 @@ +import { optionsResponse, notFoundResponse } from '../utils/cors' +import { handleHistory } from './history' +import { handleConfig, handleHealth } from './config' +import { handleWebMCPToken } from './webmcp' +import { handleComponents, handleComponentById, handleComponentUsage } from './components' +import { handleThemes, handleActiveTheme, handleDesignTokens, handleThemeById, handleThemeExport } from './themes' +import { handleCanvas, handleCanvasById, handleToolbarCanvas, handleDefaultCanvas, handleCanvasComponents, handleCanvasComponentById } from './canvas' +import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea' +import { handleTables, handleStats, handleTableSchema, handleTableData, handleQuery } from './database' + +export async function handleRequest(req: Request): Promise { + const url = new URL(req.url) + const path = url.pathname + + // CORS preflight + if (req.method === 'OPTIONS') { + return optionsResponse() + } + + // Health + if (path === '/api/health') { + return handleHealth() + } + + // History + if (path === '/api/history') { + const res = await handleHistory(req, url) + if (res) return res + } + + // Config + if (path === '/api/config') { + const res = await handleConfig(req, url) + if (res) return res + } + + // WebMCP Token + if (path === '/api/webmcp-token') { + const res = await handleWebMCPToken(req) + if (res) return res + } + + // Components + if (path === '/api/components') { + const res = await handleComponents(req) + if (res) return res + } + + // Component usage + const componentUsageMatch = path.match(/^\/api\/components\/([^/]+)\/usage$/) + if (componentUsageMatch && req.method === 'GET') { + return handleComponentUsage(componentUsageMatch[1]) + } + + // Component by ID + if (path.startsWith('/api/components/') && !path.includes('/usage')) { + const id = path.split('/').pop()! + const res = await handleComponentById(req, id) + if (res) return res + } + + // Themes + if (path === '/api/themes') { + const res = await handleThemes(req) + if (res) return res + } + + if (path === '/api/themes/active') { + return handleActiveTheme() + } + + if (path === '/api/design-tokens') { + return handleDesignTokens() + } + + // Theme export + if (path.startsWith('/api/themes/export/')) { + const id = path.split('/').pop()! + if (req.method === 'GET') { + return handleThemeExport(id) + } + } + + // Theme by ID + if (path.startsWith('/api/themes/') && !path.includes('/active') && !path.includes('/export')) { + const pathParts = path.split('/') + const id = pathParts[3] + const action = pathParts[4] + const res = await handleThemeById(req, id, action) + if (res) return res + } + + // Canvas toolbar + if (path === '/api/canvas/toolbar') { + return handleToolbarCanvas() + } + + // Canvas default + if (path === '/api/canvas/default') { + return handleDefaultCanvas() + } + + // Canvas list/create + if (path === '/api/canvas') { + const res = await handleCanvas(req) + if (res) return res + } + + // Canvas components + const canvasComponentsMatch = path.match(/^\/api\/canvas\/([^/]+)\/components\/?$/) + if (canvasComponentsMatch) { + const res = await handleCanvasComponents(req, canvasComponentsMatch[1]) + if (res) return res + } + + // Canvas component by ID + const canvasComponentMatch = path.match(/^\/api\/canvas\/([^/]+)\/components\/([^/]+)$/) + if (canvasComponentMatch) { + const res = await handleCanvasComponentById(req, canvasComponentMatch[1], canvasComponentMatch[2]) + if (res) return res + } + + // Canvas by ID + if (path.startsWith('/api/canvas/') && !path.includes('/components')) { + const pathParts = path.split('/') + const id = pathParts[3] + const action = pathParts[4] + const res = await handleCanvasById(req, id, action) + if (res) return res + } + + // Gitea + if (path === '/api/gitea/repo' && req.method === 'POST') { + return handleGiteaRepo(req) + } + + if (path === '/api/gitea/tree' && req.method === 'POST') { + return handleGiteaTree(req) + } + + if (path === '/api/gitea/file' && req.method === 'POST') { + return handleGiteaFile(req) + } + + // Database Explorer + if (path === '/api/database/tables') { + return handleTables() + } + + if (path === '/api/database/stats') { + return handleStats() + } + + // Table schema + const tableSchemaMatch = path.match(/^\/api\/database\/tables\/([^/]+)\/schema$/) + if (tableSchemaMatch && req.method === 'GET') { + return handleTableSchema(decodeURIComponent(tableSchemaMatch[1])) + } + + // Table data + const tableDataMatch = path.match(/^\/api\/database\/tables\/([^/]+)\/data$/) + if (tableDataMatch && req.method === 'GET') { + return handleTableData(decodeURIComponent(tableDataMatch[1]), url) + } + + // Database query + if (path === '/api/database/query' && req.method === 'POST') { + return handleQuery(req) + } + + return notFoundResponse() +} diff --git a/server/routes/themes.ts b/server/routes/themes.ts new file mode 100644 index 0000000..6d63800 --- /dev/null +++ b/server/routes/themes.ts @@ -0,0 +1,157 @@ +import { db } from '../db' +import { jsonResponse, errorResponse, corsHeaders } from '../utils/cors' + +function parseTheme(row: any) { + return { + ...row, + is_default: !!row.is_default, + is_system: !!row.is_system, + variables: JSON.parse(row.variables), + metadata: row.metadata ? JSON.parse(row.metadata) : null + } +} + +export async function handleThemes(req: Request) { + 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(parseTheme) + return jsonResponse(themes) + } + + 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 jsonResponse({ success: true, id }) + } + + return null +} + +export function handleActiveTheme() { + const row = db.query('SELECT * FROM themes WHERE is_default = 1 LIMIT 1').get() as any + if (!row) { + return errorResponse('No active theme', 404) + } + return jsonResponse(parseTheme(row)) +} + +export function handleDesignTokens() { + 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 jsonResponse({ + 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);' + } + }) +} + +export async function handleThemeById(req: Request, id: string, action?: string) { + // POST /api/themes/:id/default - Set as 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 jsonResponse({ success: true }) + } + + // PUT /api/themes/:id - Update theme + if (req.method === 'PUT' && !action) { + const theme = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any + if (!theme) { + return errorResponse('Theme not found', 404) + } + + const body = await req.json() + 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 jsonResponse({ success: true, id }) + } + + // GET /api/themes/:id - Get theme + if (req.method === 'GET' && !action) { + const row = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any + if (!row) { + return errorResponse('Theme not found', 404) + } + return jsonResponse(parseTheme(row)) + } + + // DELETE /api/themes/:id - Delete theme + if (req.method === 'DELETE' && !action) { + const theme = db.query('SELECT is_system FROM themes WHERE id = ?').get(id) as { is_system: number } | null + if (theme?.is_system) { + return errorResponse('Cannot delete system theme', 403) + } + db.run('DELETE FROM themes WHERE id = ?', [id]) + return jsonResponse({ success: true }) + } + + return null +} + +export function handleThemeExport(id: string) { + const row = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any + if (!row) { + return errorResponse('Theme not found', 404) + } + + 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"` + } + }) +} diff --git a/server/routes/webmcp.ts b/server/routes/webmcp.ts new file mode 100644 index 0000000..3fe7617 --- /dev/null +++ b/server/routes/webmcp.ts @@ -0,0 +1,42 @@ +import { jsonResponse, errorResponse } from '../utils/cors' + +// WebMCP token storage (in-memory) +let pendingWebMCPToken: { token: string; createdAt: Date } | null = null + +export async function handleWebMCPToken(req: Request) { + if (req.method === 'GET') { + if (pendingWebMCPToken) { + // Check if token is not expired (5 minutes) + const age = Date.now() - pendingWebMCPToken.createdAt.getTime() + if (age < 5 * 60 * 1000) { + return jsonResponse({ + token: pendingWebMCPToken.token, + createdAt: pendingWebMCPToken.createdAt.toISOString() + }) + } + // Token expired + pendingWebMCPToken = null + } + return jsonResponse({ token: null }) + } + + if (req.method === 'POST') { + const body = await req.json() + if (body.token) { + pendingWebMCPToken = { + token: body.token, + createdAt: new Date() + } + console.log('[WebMCP] Token received and stored') + return jsonResponse({ success: true }) + } + return errorResponse('Token required', 400) + } + + if (req.method === 'DELETE') { + pendingWebMCPToken = null + return jsonResponse({ success: true }) + } + + return null +} diff --git a/server/services/terminal.ts b/server/services/terminal.ts new file mode 100644 index 0000000..a5122f7 --- /dev/null +++ b/server/services/terminal.ts @@ -0,0 +1,192 @@ +import { spawn, type IPty } from '@skitee3000/bun-pty' +import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES } from '../config' + +interface TerminalSession { + id: string + pty: IPty + outputBuffer: string[] + maxBufferSize: number + clients: Set + createdAt: Date +} + +// Store active terminal sessions by ID (persistent across reconnections) +const sessions = new Map() + +// Map WebSocket to sessionId +const wsToSession = new Map() + +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, SHELL_ARGS, { + 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() + } + + // Capture output to buffer and send to clients + pty.onData((data: string) => { + session!.outputBuffer.push(data) + if (session!.outputBuffer.length > session!.maxBufferSize) { + session!.outputBuffer.shift() + } + + for (const ws of session!.clients) { + try { + ws.send(JSON.stringify({ type: 'output', data })) + } catch { + // Client disconnected + } + } + }) + + // Handle PTY exit + pty.onExit(({ exitCode }) => { + 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 { /* ignore */ } + } + sessions.delete(sessionId) + }) + + sessions.set(sessionId, session) + console.log(`[Terminal] Session ${sessionId} created, PID: ${pty.pid}`) + } + + return session +} + +export function startTerminalServer() { + const server = Bun.serve({ + port: PORT_TERMINAL, + fetch(req, server) { + const url = new URL(req.url) + + // Health check with session info + 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 + }) + } + + // List active sessions + 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') { + 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) + + // Send connection info + ws.send(JSON.stringify({ + type: 'connected', + sessionId: session.id, + isNew: session.outputBuffer.length === 0 + })) + + // Replay buffer if there's history + 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)`) + // Don't kill PTY - session persists + } + wsToSession.delete(ws) + } + } + } + }) + + console.log(`[Terminal] WebSocket running at ws://localhost:${PORT_TERMINAL}`) + return server +} diff --git a/server/utils/cors.ts b/server/utils/cors.ts new file mode 100644 index 0000000..778147a --- /dev/null +++ b/server/utils/cors.ts @@ -0,0 +1,21 @@ +export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' +} + +export function optionsResponse() { + return new Response(null, { headers: corsHeaders }) +} + +export function jsonResponse(data: unknown, status = 200) { + return Response.json(data, { status, headers: corsHeaders }) +} + +export function errorResponse(error: string, status = 400) { + return Response.json({ error }, { status, headers: corsHeaders }) +} + +export function notFoundResponse() { + return new Response('Not Found', { status: 404, headers: corsHeaders }) +}