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.
This commit is contained in:
13
server/config.ts
Normal file
13
server/config.ts
Normal file
@@ -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'
|
||||||
16
server/db/index.ts
Normal file
16
server/db/index.ts
Normal file
@@ -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 }
|
||||||
107
server/db/migrations.ts
Normal file
107
server/db/migrations.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
server/db/seeds.ts
Normal file
134
server/db/seeds.ts
Normal file
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
1397
server/index.ts
1397
server/index.ts
File diff suppressed because it is too large
Load Diff
250
server/routes/canvas.ts
Normal file
250
server/routes/canvas.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
84
server/routes/components.ts
Normal file
84
server/routes/components.ts
Normal file
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
27
server/routes/config.ts
Normal file
27
server/routes/config.ts
Normal file
@@ -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() })
|
||||||
|
}
|
||||||
119
server/routes/database.ts
Normal file
119
server/routes/database.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
server/routes/gitea.ts
Normal file
130
server/routes/gitea.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
server/routes/history.ts
Normal file
24
server/routes/history.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
172
server/routes/index.ts
Normal file
172
server/routes/index.ts
Normal file
@@ -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<Response> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
157
server/routes/themes.ts
Normal file
157
server/routes/themes.ts
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
42
server/routes/webmcp.ts
Normal file
42
server/routes/webmcp.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
192
server/services/terminal.ts
Normal file
192
server/services/terminal.ts
Normal file
@@ -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<any>
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store active terminal sessions by ID (persistent across reconnections)
|
||||||
|
const sessions = new Map<string, TerminalSession>()
|
||||||
|
|
||||||
|
// Map WebSocket to sessionId
|
||||||
|
const wsToSession = new Map<any, string>()
|
||||||
|
|
||||||
|
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=<id> - 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
|
||||||
|
}
|
||||||
21
server/utils/cors.ts
Normal file
21
server/utils/cors.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user