Compare commits

..

9 Commits

Author SHA1 Message Date
607527d98d cambios claude code 2026-02-13 18:46:27 -06:00
3a734f2426 feat: Add terminal UI control tools for MCP
- Add 5 terminal tools: open, close, toggle, move, resize
- Create terminalHandlers.ts with UI control functions
- Add terminal category to toolDefinitions and toolRegistry
- Expose FloatingTerminal controls via defineExpose
- Connect controls in App.vue via setTerminalControls
- Fix ToolsDropdown missing terminal category
2026-02-13 18:42:47 -06:00
f3f0df9cf3 feat: Redesign FloatingTerminal with Windows Vista Aero style
- Slim, transparent Aero Glass design with backdrop blur
- Draggable window from titlebar
- Resizable from corner handle
- Ctrl+E toggle to open at cursor position
- Remove minimize functionality (close returns to sidebar button)
- Add PWA dev-dist files and lock files
2026-02-13 18:27:17 -06:00
424afa060c feat: Improve WebMCP connection handling and tools management
WebMCP service:
- Add headless mode configuration
- Implement proper event handlers with unsubscribe support
- Add connection info tracking (channel, server, status, tools)
- Add destroyWebMCP for cleanup
- Improve connectWithToken to pass token directly

Canvas store:
- Add connection state (reconnecting, status, error, info)
- Add computed statusColor for UI feedback

Components:
- Add ConnectionDropdown for connection status display
- Add ToolsDropdown for tools management UI

Tool registry:
- Improve tool activation/deactivation logic
- Better error handling and logging
2026-02-13 18:06:45 -06:00
3c57f95b90 feat: Add PWA support and CORS configuration
- Configure VitePWA with manifest, icons and service worker
- Add PwaInstallBanner component for install prompt in header
- Enable CORS for z590.interno.com access
- Use dynamic hostname for terminal WebSocket connection
- Add generate-icons script with sharp for PWA icons
- Fix theme-color to match header background
2026-02-13 18:00:54 -06:00
4450d1e034 feat: Add /tools page with centralized tool registry management
- Add ToolsPage for managing MCP tools activation and persistence
- Centralize all tool handlers in services/tools/handlers/
- toolRegistry.ts is now the single source of truth for tool state
- Add tools store for pinned tools (persisted in localStorage)
- Tools can be pinned to stay active across page navigation
- Remove old individual tool files, replaced by centralized handlers
2026-02-13 13:46:55 -06:00
da6111bd1f refactor: Modularize database page into components and composables
- Extract types to types/database.ts
- Create composables: useDatabaseApi, useDataTable, useQueryExecutor
- Create components: DatabaseSidebar, DataTable, FilterBar, SchemaInfo,
  QueryEditor, QueryColumnsBar, DatabaseStats, TablePagination
- Add horizontal scroll to DataTable with sticky checkbox column
- Configure @ path alias in vite and tsconfig
- Reduce DatabasePage.vue from 1548 to 314 lines
2026-02-13 13:21:52 -06:00
421b184829 feat: Add advanced data table features to database page
- Add cell click-to-copy with visual feedback
- Add text filter to search across all columns
- Add column visibility toggle (click schema columns to show/hide)
- Add row selection with checkboxes
- Copy All respects visible columns and selected rows
- Auto-reset selections when changing table/page/query
2026-02-13 13:05:35 -06:00
645f51a74e 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.
2026-02-13 13:01:18 -06:00
85 changed files with 16339 additions and 4324 deletions

View File

@@ -36,7 +36,16 @@
"Bash(bun add:*)", "Bash(bun add:*)",
"mcp__agent-ui__localhost_4100-confetti", "mcp__agent-ui__localhost_4100-confetti",
"mcp__agent-ui__localhost_4100-get_current_page", "mcp__agent-ui__localhost_4100-get_current_page",
"mcp__agent-ui___webmcp_server-info" "mcp__agent-ui___webmcp_server-info",
"mcp__agent-ui__localhost_4100-toggle_pin_tool",
"mcp__agent-ui__localhost_4100-pin_tool",
"Bash(npx vue-tsc:*)",
"mcp__agent-ui__localhost_4100-activate_tool",
"mcp__agent-ui__localhost_4100-terminal_open",
"mcp__agent-ui__localhost_4100-terminal_move",
"mcp__agent-ui__localhost_4100-terminal_resize",
"mcp__agent-ui__localhost_4100-terminal_toggle",
"mcp__agent-ui__localhost_4100-terminal_close"
] ]
}, },
"enableAllProjectMcpServers": true, "enableAllProjectMcpServers": true,

View File

@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

View File

94
frontend/dev-dist/sw.js Normal file
View File

@@ -0,0 +1,94 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "suppress-warnings.js",
"revision": "d41d8cd98f00b204e9800998ecf8427e"
}, {
"url": "index.html",
"revision": "0.24e3u5ntq78"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/],
denylist: [/^\/api\//]
}));
}));
//# sourceMappingURL=sw.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -2,10 +2,21 @@
<html lang="es"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#16161d" />
<meta name="description" content="Dynamic canvas for Claude Code interaction via WebMCP" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Agent UI" />
<!-- Icons -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" />
<meta name="theme-color" content="#1a1a2e" /> <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta name="description" content="Dynamic canvas for Claude Code interaction" />
<title>Agent UI</title> <title>Agent UI</title>
</head> </head>
<body> <body>

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"predev": "npm install @nucleoriofrio/webmcp@git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git --silent", "predev": "npm install @nucleoriofrio/webmcp@git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git --silent",
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"generate-icons": "node scripts/generate-icons.js"
}, },
"dependencies": { "dependencies": {
"@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git", "@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
@@ -23,6 +24,7 @@
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"sharp": "^0.34.5",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vue-tsc": "^3.1.5" "vue-tsc": "^3.1.5"

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea"/>
<stop offset="100%" style="stop-color:#764ba2"/>
</linearGradient>
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4facfe"/>
<stop offset="100%" style="stop-color:#00f2fe"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
<circle cx="256" cy="200" r="80" fill="none" stroke="white" stroke-width="16" opacity="0.9"/>
<circle cx="256" cy="200" r="40" fill="url(#accent)"/>
<rect x="156" y="320" width="200" height="24" rx="12" fill="white" opacity="0.8"/>
<rect x="186" y="360" width="140" height="16" rx="8" fill="white" opacity="0.5"/>
<rect x="206" y="392" width="100" height="16" rx="8" fill="white" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 948 B

View File

@@ -0,0 +1,43 @@
import sharp from 'sharp'
import { readFileSync, mkdirSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const iconsDir = join(__dirname, '../public/icons')
// Read SVG
const svgBuffer = readFileSync(join(iconsDir, 'icon.svg'))
// Generate icons
const sizes = [
{ name: 'icon-192.png', size: 192 },
{ name: 'icon-512.png', size: 512 },
{ name: 'icon-maskable-512.png', size: 512 }
]
async function generate() {
for (const { name, size } of sizes) {
await sharp(svgBuffer)
.resize(size, size)
.png()
.toFile(join(iconsDir, name))
console.log(`Generated ${name}`)
}
// Also generate apple-touch-icon
await sharp(svgBuffer)
.resize(180, 180)
.png()
.toFile(join(iconsDir, 'apple-touch-icon.png'))
console.log('Generated apple-touch-icon.png')
// Favicon
await sharp(svgBuffer)
.resize(32, 32)
.png()
.toFile(join(__dirname, '../public/favicon.png'))
console.log('Generated favicon.png')
}
generate().catch(console.error)

View File

@@ -4,17 +4,22 @@ import { RouterView, useRoute, useRouter } from 'vue-router'
import StatusBar from './components/StatusBar.vue' import StatusBar from './components/StatusBar.vue'
import Toolbar from './components/Toolbar.vue' import Toolbar from './components/Toolbar.vue'
import ComponentsDropdown from './components/ComponentsDropdown.vue' import ComponentsDropdown from './components/ComponentsDropdown.vue'
import ToolsDropdown from './components/ToolsDropdown.vue'
import ConnectionDropdown from './components/ConnectionDropdown.vue'
import FloatingTerminal from './components/FloatingTerminal.vue' import FloatingTerminal from './components/FloatingTerminal.vue'
import PwaInstallBanner from './components/PwaInstallBanner.vue'
import { initWebMCP, getWebMCP, startTokenPolling, stopTokenPolling, connectWithToken } from './services/webmcp' import { initWebMCP, getWebMCP, startTokenPolling, stopTokenPolling, connectWithToken } from './services/webmcp'
import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry' import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry'
import { setTerminalControls } from './services/tools/handlers/terminalHandlers'
import { useCanvasStore } from './stores/canvas' import { useCanvasStore } from './stores/canvas'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const showTerminal = ref(false) const showTerminal = ref(false)
const terminalRef = ref<InstanceType<typeof FloatingTerminal> | null>(null)
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal' type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools'
onMounted(async () => { onMounted(async () => {
// Initialize WebMCP connection // Initialize WebMCP connection
@@ -27,6 +32,43 @@ onMounted(async () => {
const currentPage = (route.name as string) || 'canvas' const currentPage = (route.name as string) || 'canvas'
initToolsOnRefresh(currentPage as PageName) initToolsOnRefresh(currentPage as PageName)
// Setup terminal controls for MCP tools
setTerminalControls({
open: (x?: number, y?: number) => {
if (terminalRef.value) {
terminalRef.value.open(x, y)
} else {
showTerminal.value = true
}
},
close: () => {
if (terminalRef.value) {
terminalRef.value.close()
} else {
showTerminal.value = false
}
},
toggle: () => {
if (terminalRef.value) {
terminalRef.value.toggle()
} else {
showTerminal.value = !showTerminal.value
}
},
move: (x: number, y: number) => {
terminalRef.value?.move(x, y)
},
resize: (w: number, h: number) => {
terminalRef.value?.resize(w, h)
},
getState: () => {
if (terminalRef.value) {
return terminalRef.value.getState()
}
return { isOpen: showTerminal.value, position: { x: 0, y: 0 }, size: { w: 580, h: 360 } }
}
})
// Start polling for token if not connected // Start polling for token if not connected
const webmcp = getWebMCP() const webmcp = getWebMCP()
if (!webmcp?.isConnected) { if (!webmcp?.isConnected) {
@@ -57,7 +99,10 @@ watch(() => route.name, (newPage) => {
<header class="app-header"> <header class="app-header">
<div class="header-left"> <div class="header-left">
<h1 class="logo">Agent UI</h1> <h1 class="logo">Agent UI</h1>
<ConnectionDropdown />
<ComponentsDropdown /> <ComponentsDropdown />
<ToolsDropdown />
<PwaInstallBanner />
</div> </div>
<StatusBar /> <StatusBar />
</header> </header>
@@ -88,7 +133,7 @@ watch(() => route.name, (newPage) => {
</button> </button>
<!-- Floating Terminal --> <!-- Floating Terminal -->
<FloatingTerminal v-model="showTerminal" /> <FloatingTerminal ref="terminalRef" v-model="showTerminal" />
</div> </div>
</template> </template>
@@ -97,6 +142,7 @@ watch(() => route.name, (newPage) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
height: 100dvh;
background: var(--bg-primary); background: var(--bg-primary);
} }
@@ -105,8 +151,12 @@ watch(() => route.name, (newPage) => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
padding-top: calc(0.75rem + env(safe-area-inset-top, 0px));
padding-left: calc(1.5rem + env(safe-area-inset-left, 0px));
padding-right: calc(1.5rem + env(safe-area-inset-right, 0px));
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
} }
.header-left { .header-left {

View File

@@ -0,0 +1,363 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useCanvasStore } from '../stores/canvas'
import { connectWithToken, getConnectionInfo } from '../services/webmcp'
const canvasStore = useCanvasStore()
const { isConnected, isReconnecting, connectionStatus, connectionInfo } = storeToRefs(canvasStore)
const isOpen = ref(false)
const tokenInput = ref('')
const isConnecting = ref(false)
const statusText = computed(() => {
if (isReconnecting.value) return 'Reconnecting...'
if (isConnected.value) return 'Connected'
return 'Disconnected'
})
const statusClass = computed(() => {
if (isReconnecting.value) return 'warning'
if (isConnected.value) return 'success'
return 'error'
})
function toggleDropdown() {
isOpen.value = !isOpen.value
}
function closeDropdown(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.connection-dropdown-container')) {
isOpen.value = false
}
}
async function handleConnect() {
if (!tokenInput.value.trim()) return
isConnecting.value = true
try {
const success = await connectWithToken(tokenInput.value.trim())
if (success) {
tokenInput.value = ''
canvasStore.showNotification('Connecting to WebMCP...', 'info')
} else {
canvasStore.showNotification('Invalid token', 'error')
}
} catch (e: any) {
canvasStore.showNotification(e.message || 'Connection failed', 'error')
} finally {
isConnecting.value = false
}
}
async function handlePaste() {
try {
const text = await navigator.clipboard.readText()
tokenInput.value = text
} catch {
// Clipboard access denied
}
}
onMounted(() => {
document.addEventListener('click', closeDropdown)
})
onUnmounted(() => {
document.removeEventListener('click', closeDropdown)
})
</script>
<template>
<div class="connection-dropdown-container">
<button class="dropdown-trigger" @click.stop="toggleDropdown" title="WebMCP Connection">
<span class="status-dot" :class="statusClass"></span>
<span>MCP</span>
<svg class="chevron" :class="{ open: isOpen }" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<div v-if="isOpen" class="dropdown-menu" @click.stop>
<div class="dropdown-header">
<span class="header-title">WebMCP</span>
<span class="status-badge" :class="statusClass">{{ statusText }}</span>
</div>
<!-- Disconnected: Show token input -->
<div v-if="!isConnected" class="connect-section">
<p class="connect-hint">Paste the token from Claude Code:</p>
<div class="token-input-group">
<input
v-model="tokenInput"
type="text"
placeholder="eyJ..."
class="token-input"
@keyup.enter="handleConnect"
/>
<button class="paste-btn" @click="handlePaste" title="Paste from clipboard">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
<button
class="connect-btn"
@click="handleConnect"
:disabled="!tokenInput.trim() || isConnecting"
>
{{ isConnecting ? 'Connecting...' : 'Connect' }}
</button>
</div>
<!-- Connected: Show connection info -->
<div v-else class="info-section">
<div v-if="connectionInfo" class="info-grid">
<div class="info-item">
<span class="info-label">Channel</span>
<span class="info-value">{{ connectionInfo.channel || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">Server</span>
<span class="info-value">{{ connectionInfo.server || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">Tools</span>
<span class="info-value">{{ connectionInfo.tools?.length || 0 }} registered</span>
</div>
</div>
<div v-else class="info-empty">
Connection active
</div>
</div>
</div>
</div>
</template>
<style scoped>
.connection-dropdown-container {
position: relative;
}
.dropdown-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-hover);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.dropdown-trigger:hover {
background: var(--bg-tertiary, rgba(255,255,255,0.1));
color: var(--text-primary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.success {
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.6);
}
.status-dot.warning {
background: #f59e0b;
animation: pulse 1.5s infinite;
}
.status-dot.error {
background: #6b7280;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.chevron {
transition: transform 0.2s ease;
}
.chevron.open {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 260px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
z-index: 1000;
overflow: hidden;
}
.dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.header-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.status-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.2rem 0.5rem;
border-radius: 9999px;
}
.status-badge.success {
background: rgba(16, 185, 129, 0.15);
color: #10b981;
}
.status-badge.warning {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.status-badge.error {
background: rgba(107, 114, 128, 0.15);
color: #9ca3af;
}
.connect-section {
padding: 1rem;
}
.connect-hint {
font-size: 0.8rem;
color: var(--text-secondary);
margin: 0 0 0.75rem 0;
}
.token-input-group {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.token-input {
flex: 1;
padding: 0.5rem 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.8rem;
font-family: var(--font-mono);
}
.token-input:focus {
outline: none;
border-color: var(--accent);
}
.token-input::placeholder {
color: var(--text-muted);
}
.paste-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.paste-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--accent);
}
.connect-btn {
width: 100%;
padding: 0.6rem;
background: var(--accent);
border: none;
border-radius: 6px;
color: var(--accent-text);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.connect-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.connect-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.info-section {
padding: 1rem;
}
.info-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0;
}
.info-label {
font-size: 0.75rem;
color: var(--text-muted);
}
.info-value {
font-size: 0.8rem;
color: var(--text-primary);
font-family: var(--font-mono);
}
.info-empty {
font-size: 0.8rem;
color: var(--text-secondary);
text-align: center;
padding: 0.5rem 0;
}
</style>

View File

@@ -19,17 +19,166 @@ const isOpen = computed({
}) })
const terminalContainer = ref<HTMLElement | null>(null) const terminalContainer = ref<HTMLElement | null>(null)
const terminalRef = ref<HTMLElement | null>(null)
const connected = ref(false) const connected = ref(false)
const connecting = ref(false) const connecting = ref(false)
const sessionId = ref<string | null>(null) const sessionId = ref<string | null>(null)
const isMinimized = ref(false)
const isDragging = ref(false)
const position = ref({ x: 0, y: 0 })
const hasCustomPosition = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
// Resize state
const isResizing = ref(false)
const size = ref({ w: 580, h: 360 })
let terminal: Terminal | null = null let terminal: Terminal | null = null
let fitAddon: FitAddon | null = null let fitAddon: FitAddon | null = null
let socket: WebSocket | null = null let socket: WebSocket | null = null
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
const WS_URL = 'ws://localhost:4103' const WS_URL = `ws://${window.location.hostname}:4103`
// Mouse position tracking for Ctrl+E
const mousePos = ref({ x: 0, y: 0 })
let lastToggle = 0
function trackMouse(e: MouseEvent) {
mousePos.value = { x: e.clientX, y: e.clientY }
}
function toggleTerminal() {
const now = Date.now()
if (now - lastToggle < 150) return // Debounce 150ms
lastToggle = now
if (!isOpen.value) {
// Open at mouse position (allow 75% occlusion)
const w = size.value.w
const h = size.value.h
const minX = -w * 0.75
const maxX = window.innerWidth - w * 0.25
const minY = -h * 0.75
const maxY = window.innerHeight - h * 0.25
position.value = {
x: Math.max(minX, Math.min(mousePos.value.x - w / 2, maxX)),
y: Math.max(minY, Math.min(mousePos.value.y - h / 2, maxY))
}
hasCustomPosition.value = true
isOpen.value = true
} else {
isOpen.value = false
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
toggleTerminal()
}
}
function startDrag(e: MouseEvent) {
if ((e.target as HTMLElement).closest('.window-controls')) return
isDragging.value = true
const rect = terminalRef.value?.getBoundingClientRect()
if (rect) {
// Capture actual position if using default bottom/right
if (!hasCustomPosition.value) {
position.value = { x: rect.left, y: rect.top }
}
dragOffset.value = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}
function onDrag(e: MouseEvent) {
if (!isDragging.value) return
const newX = e.clientX - dragOffset.value.x
const newY = e.clientY - dragOffset.value.y
const w = terminalRef.value?.offsetWidth || 580
const h = terminalRef.value?.offsetHeight || 360
// Allow up to 75% occlusion per side (25% must remain visible)
const minX = -w * 0.75
const maxX = window.innerWidth - w * 0.25
const minY = -h * 0.75
const maxY = window.innerHeight - h * 0.25
position.value = {
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY))
}
}
function stopDrag() {
isDragging.value = false
hasCustomPosition.value = true
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
// Resize functions
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
function startResize(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
isResizing.value = true
resizeStart.value = {
x: e.clientX,
y: e.clientY,
w: size.value.w,
h: size.value.h
}
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
}
function onResize(e: MouseEvent) {
if (!isResizing.value) return
const deltaX = e.clientX - resizeStart.value.x
const deltaY = e.clientY - resizeStart.value.y
size.value = {
w: Math.max(400, Math.min(resizeStart.value.w + deltaX, window.innerWidth - 40)),
h: Math.max(250, Math.min(resizeStart.value.h + deltaY, window.innerHeight - 40))
}
}
function stopResize() {
isResizing.value = false
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
nextTick(() => fitAddon?.fit())
}
const terminalStyle = computed(() => {
const base = {
width: `${size.value.w}px`,
height: `${size.value.h}px`
}
if (!hasCustomPosition.value) {
return { ...base, bottom: '16px', right: '16px' }
}
return {
...base,
top: `${position.value.y}px`,
left: `${position.value.x}px`,
bottom: 'auto',
right: 'auto'
}
})
function initTerminal() { function initTerminal() {
if (!terminalContainer.value || terminal) return if (!terminalContainer.value || terminal) return
@@ -37,30 +186,30 @@ function initTerminal() {
terminal = new Terminal({ terminal = new Terminal({
cursorBlink: true, cursorBlink: true,
cursorStyle: 'block', cursorStyle: 'block',
fontSize: 13, fontSize: 12,
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace", fontFamily: "'Consolas', 'Lucida Console', monospace",
theme: { theme: {
background: '#0f0f14', background: 'rgba(12, 12, 12, 0.95)',
foreground: '#e4e4e7', foreground: '#ffffff',
cursor: '#6366f1', cursor: '#ffffff',
cursorAccent: '#0f0f14', cursorAccent: '#000000',
selectionBackground: 'rgba(99, 102, 241, 0.3)', selectionBackground: 'rgba(100, 150, 255, 0.4)',
black: '#16161d', black: '#0c0c0c',
red: '#ef4444', red: '#c50f1f',
green: '#22c55e', green: '#13a10e',
yellow: '#eab308', yellow: '#c19c00',
blue: '#3b82f6', blue: '#0037da',
magenta: '#a855f7', magenta: '#881798',
cyan: '#06b6d4', cyan: '#3a96dd',
white: '#e4e4e7', white: '#cccccc',
brightBlack: '#52525b', brightBlack: '#767676',
brightRed: '#f87171', brightRed: '#e74856',
brightGreen: '#4ade80', brightGreen: '#16c60c',
brightYellow: '#facc15', brightYellow: '#f9f1a5',
brightBlue: '#60a5fa', brightBlue: '#3b78ff',
brightMagenta: '#c084fc', brightMagenta: '#b4009e',
brightCyan: '#22d3ee', brightCyan: '#61d6d6',
brightWhite: '#ffffff' brightWhite: '#f2f2f2'
}, },
allowProposedApi: true allowProposedApi: true
}) })
@@ -68,15 +217,12 @@ function initTerminal() {
fitAddon = new FitAddon() fitAddon = new FitAddon()
terminal.loadAddon(fitAddon) terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebLinksAddon()) terminal.loadAddon(new WebLinksAddon())
terminal.open(terminalContainer.value) terminal.open(terminalContainer.value)
nextTick(() => { nextTick(() => fitAddon?.fit())
fitAddon?.fit()
})
resizeObserver = new ResizeObserver(() => { resizeObserver = new ResizeObserver(() => {
if (fitAddon && terminal && !isMinimized.value) { if (fitAddon && terminal) {
fitAddon.fit() fitAddon.fit()
if (socket && socket.readyState === WebSocket.OPEN) { if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
@@ -94,11 +240,20 @@ function initTerminal() {
socket.send(JSON.stringify({ type: 'input', data })) socket.send(JSON.stringify({ type: 'input', data }))
} }
}) })
// Capture Ctrl+E even when terminal has focus
terminal.attachCustomKeyEventHandler((e) => {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
toggleTerminal()
return false // Prevent terminal from processing
}
return true // Let terminal handle other keys
})
} }
async function connect() { async function connect() {
if (connecting.value || connected.value) return if (connecting.value || connected.value) return
connecting.value = true connecting.value = true
try { try {
@@ -108,7 +263,6 @@ async function connect() {
connected.value = true connected.value = true
connecting.value = false connecting.value = false
terminal?.focus() terminal?.focus()
if (terminal) { if (terminal) {
socket?.send(JSON.stringify({ socket?.send(JSON.stringify({
type: 'resize', type: 'resize',
@@ -123,7 +277,7 @@ async function connect() {
if (msg.type === 'connected') { if (msg.type === 'connected') {
sessionId.value = msg.sessionId sessionId.value = msg.sessionId
if (!msg.isNew) { if (!msg.isNew) {
terminal?.write('\x1b[36m[Reconnected to session]\x1b[0m\r\n') terminal?.write('\x1b[36m[Reconnected]\x1b[0m\r\n')
} }
} else if (msg.type === 'replay') { } else if (msg.type === 'replay') {
terminal?.write(msg.data) terminal?.write(msg.data)
@@ -150,24 +304,6 @@ async function connect() {
} }
} }
function disconnect() {
if (socket) {
socket.close()
socket = null
}
connected.value = false
}
function toggleMinimize() {
isMinimized.value = !isMinimized.value
if (!isMinimized.value) {
nextTick(() => {
fitAddon?.fit()
terminal?.focus()
})
}
}
function close() { function close() {
isOpen.value = false isOpen.value = false
} }
@@ -178,24 +314,30 @@ function runClaude() {
} }
} }
// Watch for open state
watch(isOpen, async (open) => { watch(isOpen, async (open) => {
if (open) { if (open) {
await nextTick() await nextTick()
if (!terminal) {
initTerminal() initTerminal()
} if (!connected.value && !connecting.value) connect()
if (!connected.value && !connecting.value) {
connect()
}
nextTick(() => { nextTick(() => {
fitAddon?.fit() fitAddon?.fit()
terminal?.focus() terminal?.focus()
}) })
} else {
// Cleanup when closing
resizeObserver?.disconnect()
resizeObserver = null
terminal?.dispose()
terminal = null
fitAddon = null
} }
}) })
onMounted(async () => { onMounted(async () => {
// Global listeners for Ctrl+E
document.addEventListener('mousemove', trackMouse)
document.addEventListener('keydown', handleKeydown)
if (isOpen.value) { if (isOpen.value) {
await nextTick() await nextTick()
initTerminal() initTerminal()
@@ -207,46 +349,77 @@ onBeforeUnmount(() => {
resizeObserver?.disconnect() resizeObserver?.disconnect()
socket?.close() socket?.close()
terminal?.dispose() terminal?.dispose()
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
document.removeEventListener('mousemove', trackMouse)
document.removeEventListener('keydown', handleKeydown)
})
// Expose controls for MCP tools
defineExpose({
open: (x?: number, y?: number) => {
if (x !== undefined && y !== undefined) {
position.value = { x, y }
hasCustomPosition.value = true
}
isOpen.value = true
},
close: () => {
isOpen.value = false
},
toggle: () => {
toggleTerminal()
},
move: (x: number, y: number) => {
position.value = { x, y }
hasCustomPosition.value = true
},
resize: (w: number, h: number) => {
size.value = { w: Math.max(400, w), h: Math.max(250, h) }
nextTick(() => fitAddon?.fit())
},
getState: () => ({
isOpen: isOpen.value,
position: position.value,
size: size.value
})
}) })
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="terminal-slide"> <Transition name="win-slide">
<div v-if="isOpen" class="floating-terminal" :class="{ minimized: isMinimized }"> <div
<!-- Header --> v-if="isOpen"
<div class="terminal-header" @dblclick="toggleMinimize"> ref="terminalRef"
<div class="header-left"> class="aero-win"
<div class="traffic-lights"> :class="{ dragging: isDragging, resizing: isResizing }"
<button class="light red" @click="close" title="Close"></button> :style="terminalStyle"
<button class="light yellow" @click="toggleMinimize" title="Minimize"></button> >
<button class="light green" @click="runClaude" title="Run Claude"></button> <div class="glass">
<!-- Titlebar -->
<div class="titlebar" @mousedown="startDrag">
<div class="left">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</svg>
<span>Terminal</span>
<i class="dot" :class="{ on: connected, wait: connecting }"></i>
<a v-if="!connected && !connecting" class="link" @click.stop="connect">connect</a>
</div> </div>
<span class="terminal-title"> <div class="window-controls">
Terminal <button @click="runClaude" title="Claude"><svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></button>
<span v-if="sessionId" class="session-id">{{ sessionId }}</span> <button class="x" @click="close" title="Close"><svg width="8" height="8" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/></svg></button>
</span>
</div>
<div class="header-right">
<span v-if="connected" class="status-dot connected"></span>
<span v-else-if="connecting" class="status-dot connecting"></span>
<span v-else class="status-dot disconnected"></span>
<button v-if="!connected" class="btn-connect" @click="connect" :disabled="connecting">
Connect
</button>
</div> </div>
</div> </div>
<!-- Content -->
<!-- Terminal body --> <div class="content">
<div v-show="!isMinimized" class="terminal-body"> <div ref="terminalContainer" class="term"></div>
<div ref="terminalContainer" class="terminal-container"></div>
</div> </div>
<!-- Resize handle -->
<!-- Minimized bar --> <div class="resize-handle" @mousedown="startResize"></div>
<div v-if="isMinimized" class="minimized-bar" @click="toggleMinimize">
<span>Click to expand</span>
</div> </div>
</div> </div>
</Transition> </Transition>
@@ -254,237 +427,157 @@ onBeforeUnmount(() => {
</template> </template>
<style scoped> <style scoped>
.floating-terminal { .aero-win {
position: fixed; position: fixed;
bottom: 20px; min-width: 400px;
right: 20px; min-height: 250px;
width: 700px; z-index: 9999;
height: 450px; }
background: #0f0f14;
border-radius: 12px; .glass {
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1); position: relative;
width: 100%;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: rgba(200,215,235,0.35);
backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.6);
border-radius: 5px;
border: 1px solid rgba(255,255,255,0.6);
box-shadow:
0 0 0 1px rgba(80,120,180,0.25),
0 6px 24px rgba(0,0,0,0.25),
inset 0 1px 0 rgba(255,255,255,0.6);
overflow: hidden; overflow: hidden;
z-index: 9999;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.floating-terminal.minimized { .titlebar {
height: auto;
}
/* Header */
.terminal-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px 16px; height: 22px;
background: #16161d; padding: 0 2px 0 6px;
border-bottom: 1px solid #2a2a3a; background: rgba(255,255,255,0.25);
cursor: default; border-bottom: 1px solid rgba(255,255,255,0.3);
cursor: grab;
user-select: none; user-select: none;
} }
.aero-win.dragging .titlebar { cursor: grabbing; }
.header-left { .left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 5px;
color: #222;
font: 500 10px/1 system-ui, sans-serif;
} }
.traffic-lights { .dot {
display: flex; width: 5px; height: 5px;
gap: 8px;
}
.light {
width: 12px;
height: 12px;
border-radius: 50%; border-radius: 50%;
border: none; background: #999;
}
.dot.on { background: #0a0; box-shadow: 0 0 4px #0a0; }
.dot.wait { background: #a80; animation: pulse .8s infinite; }
.link {
margin-left: 2px;
color: #369;
font-size: 9px;
text-decoration: underline;
cursor: pointer; cursor: pointer;
transition: opacity 0.15s;
} }
.link:hover { color: #47a; }
.light:hover { .window-controls {
opacity: 0.8; display: flex;
gap: 1px;
} }
.window-controls button {
.light.red { width: 20px;
background: #ef4444; height: 16px;
}
.light.yellow {
background: #eab308;
}
.light.green {
background: #22c55e;
}
.terminal-title {
font-size: 13px;
font-weight: 500;
color: #a1a1aa;
}
.session-id {
margin-left: 8px;
padding: 2px 8px;
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
border-radius: 4px;
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
}
.header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; justify-content: center;
} background: rgba(255,255,255,0.3);
border: 1px solid rgba(0,0,0,0.1);
.status-dot { border-radius: 2px;
width: 8px; color: #333;
height: 8px;
border-radius: 50%;
}
.status-dot.connected {
background: #22c55e;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
}
.status-dot.connecting {
background: #eab308;
animation: pulse 1s infinite;
}
.status-dot.disconnected {
background: #52525b;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.btn-connect {
padding: 4px 12px;
background: #6366f1;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: background 0.15s; }
.window-controls button:hover {
background: rgba(255,255,255,0.5);
}
.window-controls button.x:hover {
background: linear-gradient(180deg, #e66 0%, #c33 100%);
border-color: #a22;
color: #fff;
} }
.btn-connect:hover:not(:disabled) { .content {
background: #818cf8;
}
.btn-connect:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Terminal body */
.terminal-body {
flex: 1; flex: 1;
margin: 2px;
border-radius: 2px;
overflow: hidden; overflow: hidden;
padding: 8px; background: rgba(0,0,0,0.92);
} }
.terminal-container { .resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.1) 100%);
border-radius: 0 0 5px 0;
}
.resize-handle:hover {
background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.5) 50%, rgba(255,255,255,0.2) 100%);
}
.aero-win.resizing {
user-select: none;
}
.aero-win.resizing .term {
pointer-events: none;
}
.term {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.term :deep(.xterm) {
.terminal-container :deep(.xterm) {
height: 100%; height: 100%;
padding: 2px;
} }
.term :deep(.xterm-viewport) {
.terminal-container :deep(.xterm-viewport) {
overflow-y: auto !important; overflow-y: auto !important;
} }
.term :deep(.xterm-viewport::-webkit-scrollbar) {
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
width: 8px; width: 8px;
background: rgba(0,0,0,0.2);
} }
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) { background: rgba(255,255,255,0.15);
background: transparent;
}
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
background: #2a2a3a;
border-radius: 4px; border-radius: 4px;
} }
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
/* Minimized bar */ background: rgba(255,255,255,0.25);
.minimized-bar {
padding: 8px 16px;
text-align: center;
color: #52525b;
font-size: 12px;
cursor: pointer;
} }
.minimized-bar:hover { .win-slide-enter-active, .win-slide-leave-active { transition: all .15s ease; }
color: #a1a1aa; .win-slide-enter-from, .win-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
}
/* Transition */ @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
.terminal-slide-enter-active,
.terminal-slide-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.terminal-slide-enter-from, @media (max-width: 640px) {
.terminal-slide-leave-to { .aero-win {
opacity: 0; inset: auto 0 0 0 !important;
transform: translateY(100px) scale(0.95); width: 100% !important;
} height: 55% !important;
/* Mobile responsive */
@media (max-width: 768px) {
.floating-terminal {
bottom: 0;
right: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 0;
}
.floating-terminal.minimized {
top: auto;
bottom: 0;
height: auto;
border-radius: 16px 16px 0 0;
}
.terminal-header {
padding: 16px;
}
.traffic-lights {
gap: 10px;
}
.light {
width: 14px;
height: 14px;
}
}
/* Tablet */
@media (min-width: 769px) and (max-width: 1024px) {
.floating-terminal {
width: 550px;
height: 400px;
} }
.glass { border-radius: 6px 6px 0 0; }
} }
</style> </style>

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
const installPrompt = ref<BeforeInstallPromptEvent | null>(null)
const isInstalled = ref(false)
const isPwa = ref(false)
const dismissed = ref(false)
// Check if running as PWA (standalone mode)
const checkPwaMode = () => {
isPwa.value = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true ||
document.referrer.includes('android-app://')
}
// Check if app is already installed
const checkInstalled = () => {
if ('getInstalledRelatedApps' in navigator) {
(navigator as any).getInstalledRelatedApps().then((apps: any[]) => {
isInstalled.value = apps.length > 0
}).catch(() => {})
}
}
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault()
installPrompt.value = e as BeforeInstallPromptEvent
}
const handleAppInstalled = () => {
isInstalled.value = true
installPrompt.value = null
}
const install = async () => {
if (!installPrompt.value) return
await installPrompt.value.prompt()
const { outcome } = await installPrompt.value.userChoice
if (outcome === 'accepted') {
isInstalled.value = true
}
installPrompt.value = null
}
const dismiss = () => {
dismissed.value = true
sessionStorage.setItem('pwa-dismissed', 'true')
}
const showBanner = computed(() => {
if (isPwa.value) return false
if (dismissed.value) return false
return installPrompt.value !== null
})
onMounted(() => {
checkPwaMode()
checkInstalled()
// Check if dismissed this session
dismissed.value = sessionStorage.getItem('pwa-dismissed') === 'true'
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)
// Listen for display mode changes
window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPwaMode)
})
onUnmounted(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handleAppInstalled)
})
</script>
<template>
<Transition name="banner">
<div v-if="showBanner" class="pwa-banner">
<div class="banner-content">
<div class="banner-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</div>
<span class="banner-text">Instalar Agent UI</span>
</div>
<div class="banner-actions">
<button class="btn-install" @click="install">
Instalar
</button>
<button class="btn-dismiss" @click="dismiss" title="Cerrar">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
</Transition>
</template>
<style scoped>
.pwa-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 0.75rem;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
margin-left: 1rem;
}
.banner-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.banner-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 6px;
color: white;
}
.banner-text {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
}
.banner-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-install {
padding: 0.35rem 0.75rem;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-install:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.btn-dismiss {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
}
.btn-dismiss:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
/* Transition */
.banner-enter-active,
.banner-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.banner-enter-from,
.banner-leave-to {
opacity: 0;
transform: translateX(-10px);
}
/* Responsive */
@media (max-width: 640px) {
.pwa-banner {
position: fixed;
bottom: 80px;
left: 16px;
right: 16px;
margin: 0;
z-index: 9990;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.banner-text {
font-size: 0.9rem;
}
}
</style>

View File

@@ -125,6 +125,12 @@ onMounted(() => {
<line x1="12" y1="19" x2="20" y2="19"/> <line x1="12" y1="19" x2="20" y2="19"/>
</svg> </svg>
</RouterLink> </RouterLink>
<RouterLink to="/tools" class="toolbar-btn" :class="{ active: route.path === '/tools' }" title="Tools">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
</RouterLink>
</div> </div>
<div class="toolbar-divider"></div> <div class="toolbar-divider"></div>

View File

@@ -0,0 +1,392 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useToolsStore } from '../stores/tools'
import {
activateCategory,
deactivateCategory,
syncStoreWithActiveTools
} from '../services/toolRegistry'
import { CATEGORY_INFO, type ToolCategory } from '../services/tools/toolDefinitions'
const toolsStore = useToolsStore()
const { activeTools, pinnedTools } = storeToRefs(toolsStore)
const isOpen = ref(false)
// Category to tools mapping
const categoryTools: Record<ToolCategory, string[]> = {
global: ['get_current_page', 'navigate_to', 'list_available_tools', 'activate_tool', 'deactivate_tool', 'pin_tool'],
canvas: ['render_html', 'render_vue_component'],
component: ['save_vue_component', 'load_vue_component', 'list_vue_components', 'delete_vue_component'],
theme: ['get_design_tokens', 'get_active_theme', 'set_theme_variable', 'save_theme', 'list_themes', 'switch_theme', 'reset_theme'],
database: ['list_tables', 'get_table_schema', 'get_table_data', 'get_database_stats', 'execute_query'],
source: ['get_repo_info', 'list_repo_files', 'read_repo_file', 'search_repo_code'],
project: ['list_canvases', 'create_canvas', 'get_canvas', 'update_canvas', 'delete_canvas', 'clone_canvas', 'add_component_to_canvas', 'remove_component_from_canvas', 'get_canvas_components'],
terminal: ['terminal_open', 'terminal_close', 'terminal_toggle', 'terminal_move', 'terminal_resize']
}
const categories = computed(() => {
return Object.entries(CATEGORY_INFO).map(([key, info]) => {
const tools = categoryTools[key as ToolCategory]
const activeCount = tools.filter(t => activeTools.value.includes(t)).length
const pinnedCount = tools.filter(t => pinnedTools.value.includes(t)).length
const allPinned = tools.every(t => pinnedTools.value.includes(t))
return {
key: key as ToolCategory,
...info,
tools,
activeCount,
totalCount: tools.length,
pinnedCount,
allPinned
}
})
})
const totalPinned = computed(() => pinnedTools.value.length)
const totalActive = computed(() => activeTools.value.length)
function toggleDropdown() {
isOpen.value = !isOpen.value
if (isOpen.value) {
syncStoreWithActiveTools()
}
}
function closeDropdown(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.tools-dropdown-container')) {
isOpen.value = false
}
}
async function handlePinCategory(category: ToolCategory) {
const tools = categoryTools[category]
const allPinned = tools.every(t => pinnedTools.value.includes(t))
if (allPinned) {
// Unpin all tools in category
for (const tool of tools) {
toolsStore.unpinTool(tool)
}
} else {
// Pin all tools in category and activate them
for (const tool of tools) {
toolsStore.pinTool(tool)
}
await activateCategory(category)
}
syncStoreWithActiveTools()
}
async function handleActivateCategory(category: ToolCategory) {
await activateCategory(category)
syncStoreWithActiveTools()
}
function handleDeactivateCategory(category: ToolCategory) {
deactivateCategory(category)
syncStoreWithActiveTools()
}
onMounted(() => {
document.addEventListener('click', closeDropdown)
})
onUnmounted(() => {
document.removeEventListener('click', closeDropdown)
})
</script>
<template>
<div class="tools-dropdown-container">
<button class="dropdown-trigger" @click.stop="toggleDropdown" title="Herramientas MCP">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
<span>Tools</span>
<span v-if="totalPinned > 0" class="badge">{{ totalPinned }}</span>
<svg class="chevron" :class="{ open: isOpen }" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<div v-if="isOpen" class="dropdown-menu" @click.stop>
<div class="dropdown-header">
<span class="header-title">Tool Categories</span>
<span class="header-stats">{{ totalActive }} active</span>
</div>
<div class="categories-list">
<div
v-for="cat in categories"
:key="cat.key"
class="category-item"
:style="{ '--cat-color': cat.color }"
>
<div class="category-info">
<div class="category-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path :d="cat.icon"/>
</svg>
</div>
<div class="category-details">
<span class="category-name">{{ cat.label }}</span>
<span class="category-count">{{ cat.activeCount }}/{{ cat.totalCount }} active</span>
</div>
</div>
<div class="category-actions">
<button
class="action-btn pin-btn"
:class="{ pinned: cat.allPinned }"
@click="handlePinCategory(cat.key)"
:title="cat.allPinned ? 'Unpin category' : 'Pin category (keep active)'"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 17v5"/>
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v4.76z"/>
</svg>
</button>
<button
class="action-btn activate-btn"
@click="handleActivateCategory(cat.key)"
title="Activate all"
:disabled="cat.activeCount === cat.totalCount"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
<button
class="action-btn deactivate-btn"
@click="handleDeactivateCategory(cat.key)"
title="Deactivate all"
:disabled="cat.activeCount === 0 || cat.allPinned"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
</div>
<div class="dropdown-footer">
<RouterLink to="/tools" class="manage-link" @click="isOpen = false">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Manage all tools
</RouterLink>
</div>
</div>
</div>
</template>
<style scoped>
.tools-dropdown-container {
position: relative;
}
.dropdown-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-hover);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.dropdown-trigger:hover {
background: var(--bg-tertiary, rgba(255,255,255,0.1));
color: var(--text-primary);
}
.badge {
background: #f59e0b;
color: white;
font-size: 0.7rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
min-width: 18px;
text-align: center;
}
.chevron {
transition: transform 0.2s ease;
}
.chevron.open {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 280px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
z-index: 1000;
overflow: hidden;
}
.dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.header-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.header-stats {
font-size: 0.7rem;
color: var(--text-muted);
}
.categories-list {
padding: 0.5rem;
}
.category-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.625rem;
border-radius: 8px;
margin-bottom: 0.25rem;
transition: background 0.15s;
}
.category-item:hover {
background: var(--bg-hover);
}
.category-info {
display: flex;
align-items: center;
gap: 0.625rem;
}
.category-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--bg-primary);
border-radius: 6px;
color: var(--cat-color);
}
.category-details {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.category-name {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-primary);
}
.category-count {
font-size: 0.65rem;
color: var(--text-muted);
}
.category-actions {
display: flex;
gap: 0.25rem;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 1px solid var(--border-color);
border-radius: 5px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover:not(:disabled) {
background: var(--bg-primary);
color: var(--text-primary);
}
.action-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.pin-btn:hover:not(:disabled) {
color: #f59e0b;
border-color: #f59e0b;
}
.pin-btn.pinned {
color: #f59e0b;
background: rgba(245, 158, 11, 0.15);
border-color: #f59e0b;
}
.activate-btn:hover:not(:disabled) {
color: #10b981;
border-color: #10b981;
}
.deactivate-btn:hover:not(:disabled) {
color: #ef4444;
border-color: #ef4444;
}
.dropdown-footer {
padding: 0.5rem;
border-top: 1px solid var(--border-color);
}
.manage-link {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 6px;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.8rem;
transition: all 0.15s;
}
.manage-link:hover {
background: var(--bg-hover);
color: var(--accent);
}
</style>

View File

@@ -0,0 +1,220 @@
<script setup lang="ts">
const props = defineProps<{
data: any[]
visibleColumns: string[]
selectedRows: Set<number>
allSelected: boolean
copiedCell: string | null
cellIdPrefix?: string
}>()
const emit = defineEmits<{
toggleRow: [idx: number]
toggleAll: []
copyCell: [value: any, cellId: string]
}>()
function formatValue(value: any): string {
if (value === null) return 'NULL'
if (typeof value === 'object') return JSON.stringify(value)
if (typeof value === 'string' && value.length > 100) {
return value.substring(0, 100) + '...'
}
return String(value)
}
function getCellId(idx: number, key: string): string {
return `${props.cellIdPrefix || 'cell'}-${idx}-${key}`
}
</script>
<template>
<div class="data-table-container">
<div v-if="data.length > 0 && visibleColumns.length > 0" class="data-table-scroll">
<table class="data-table">
<thead>
<tr>
<th class="checkbox-col">
<input
type="checkbox"
:checked="allSelected"
@change="emit('toggleAll')"
title="Select all"
/>
</th>
<th v-for="key in visibleColumns" :key="key">{{ key }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, idx) in data"
:key="idx"
:class="{ 'row-selected': selectedRows.has(idx) }"
>
<td class="checkbox-col">
<input
type="checkbox"
:checked="selectedRows.has(idx)"
@change="emit('toggleRow', idx)"
/>
</td>
<td
v-for="key in visibleColumns"
:key="key"
class="copyable"
:class="{ copied: copiedCell === getCellId(idx, key) }"
@click="emit('copyCell', row[key], getCellId(idx, key))"
title="Click to copy"
>
<span class="cell-content">{{ formatValue(row[key]) }}</span>
<span v-if="copiedCell === getCellId(idx, key)" class="copied-badge">Copied!</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else-if="visibleColumns.length === 0" class="no-data">All columns hidden</div>
<div v-else class="no-data">
<slot name="empty">No records found</slot>
</div>
</div>
</template>
<style scoped>
.data-table-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.data-table-scroll {
flex: 1;
overflow: auto;
padding: 1rem;
}
.data-table {
min-width: max-content;
border-collapse: collapse;
font-size: 0.85rem;
}
.data-table th,
.data-table td {
padding: 0.625rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
white-space: nowrap;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
.data-table th {
background: var(--bg-secondary);
color: var(--text-secondary);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
position: sticky;
top: 0;
z-index: 2;
}
.data-table td {
color: var(--text-primary);
}
.data-table tr:hover td {
background: var(--bg-hover);
}
.checkbox-col {
width: 40px;
min-width: 40px;
text-align: center;
padding: 0.5rem !important;
position: sticky;
left: 0;
z-index: 1;
background: var(--bg-secondary);
}
.data-table td.checkbox-col {
background: var(--bg-primary);
}
.data-table tr:hover td.checkbox-col {
background: var(--bg-hover);
}
.data-table tr.row-selected td.checkbox-col {
background: var(--accent-muted);
}
.checkbox-col input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--accent);
}
.data-table tr.row-selected td {
background: var(--accent-muted);
}
.data-table tr.row-selected:hover td {
background: var(--accent-muted);
filter: brightness(0.95);
}
.data-table td.copyable {
cursor: pointer;
position: relative;
transition: background 0.15s;
}
.data-table td.copyable:hover {
background: var(--accent-muted);
}
.data-table td.copyable:active {
background: var(--accent);
color: white;
}
.data-table td.copied {
background: var(--success-bg, #d4edda);
}
.cell-content {
display: block;
}
.copied-badge {
position: absolute;
top: 50%;
right: 0.5rem;
transform: translateY(-50%);
padding: 0.125rem 0.375rem;
background: var(--accent);
color: white;
font-size: 0.65rem;
font-weight: 600;
border-radius: 4px;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-50%) scale(0.9); }
to { opacity: 1; transform: translateY(-50%) scale(1); }
}
.no-data {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
import type { TableInfo, DbStats } from '@/types/database'
defineProps<{
tables: TableInfo[]
selectedTable: string | null
stats: DbStats | null
loading: boolean
error: string | null
}>()
const emit = defineEmits<{
selectTable: [tableName: string]
refresh: []
}>()
</script>
<template>
<aside class="sidebar">
<div class="sidebar-header">
<h2>Database</h2>
<button class="btn-icon" @click="emit('refresh')" title="Refresh">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
</svg>
</button>
</div>
<div class="table-list">
<div v-if="loading && tables.length === 0" class="loading">Loading tables...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div
v-else
v-for="table in tables"
:key="table.name"
class="table-item"
:class="{ active: selectedTable === table.name }"
@click="emit('selectTable', table.name)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M3 15h18"/>
<path d="M9 3v18"/>
</svg>
<span class="table-name">{{ table.name }}</span>
<span class="table-count">{{ table.count }}</span>
</div>
</div>
<div v-if="stats" class="sidebar-stats">
<div class="stat-item">
<span class="stat-label">Size</span>
<span class="stat-value">{{ stats.size }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Tables</span>
<span class="stat-value">{{ stats.tables }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Records</span>
<span class="stat-value">{{ stats.totalRecords }}</span>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 280px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h2 {
margin: 0;
font-size: 1rem;
color: var(--text-primary);
}
.btn-icon {
padding: 0.375rem;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--accent);
}
.table-list {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
}
.table-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
color: var(--text-secondary);
}
.table-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.table-item.active {
background: var(--accent-muted);
color: var(--accent);
}
.table-name {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
}
.table-count {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background: var(--bg-tertiary);
border-radius: 9999px;
color: var(--text-muted);
}
.table-item.active .table-count {
background: var(--accent);
color: white;
}
.sidebar-stats {
padding: 1rem;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stat-item {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
}
.stat-label {
color: var(--text-muted);
}
.stat-value {
color: var(--text-primary);
font-weight: 500;
}
.loading, .error {
padding: 1rem;
text-align: center;
font-size: 0.875rem;
}
.loading {
color: var(--text-muted);
}
.error {
color: var(--error);
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
max-height: 200px;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.table-list {
flex-direction: row;
flex-wrap: wrap;
overflow-x: auto;
padding: 0.5rem;
}
.table-item {
flex: 0 0 auto;
}
.sidebar-stats {
display: none;
}
}
</style>

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import type { DbStats, TableInfo } from '@/types/database'
defineProps<{
stats: DbStats | null
tables: TableInfo[]
}>()
</script>
<template>
<div class="stats-tab">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
</svg>
</div>
<div class="stat-content">
<span class="stat-title">Database Size</span>
<span class="stat-value-lg">{{ stats?.size || '-' }}</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M9 3v18"/>
</svg>
</div>
<div class="stat-content">
<span class="stat-title">Total Tables</span>
<span class="stat-value-lg">{{ stats?.tables || 0 }}</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</div>
<div class="stat-content">
<span class="stat-title">Total Records</span>
<span class="stat-value-lg">{{ stats?.totalRecords || 0 }}</span>
</div>
</div>
</div>
<div class="tables-breakdown">
<h3>Tables Breakdown</h3>
<div class="breakdown-list">
<div v-for="table in tables" :key="table.name" class="breakdown-item">
<span class="breakdown-name">{{ table.name }}</span>
<div class="breakdown-bar">
<div
class="breakdown-fill"
:style="{ width: `${(table.count / (stats?.totalRecords || 1)) * 100}%` }"
></div>
</div>
<span class="breakdown-count">{{ table.count }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.stats-tab {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
overflow-y: auto;
flex: 1;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.stat-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
}
.stat-icon {
padding: 0.75rem;
background: var(--accent-muted);
border-radius: 10px;
color: var(--accent);
}
.stat-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat-title {
font-size: 0.8rem;
color: var(--text-muted);
}
.stat-value-lg {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
.tables-breakdown {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.25rem;
}
.tables-breakdown h3 {
margin: 0 0 1rem 0;
font-size: 0.9rem;
color: var(--text-primary);
}
.breakdown-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.breakdown-item {
display: flex;
align-items: center;
gap: 1rem;
}
.breakdown-name {
width: 150px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.breakdown-bar {
flex: 1;
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.breakdown-fill {
height: 100%;
background: var(--accent);
border-radius: 4px;
min-width: 4px;
transition: width 0.3s ease;
}
.breakdown-count {
width: 60px;
text-align: right;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-primary);
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: string
rowCount: number
totalCount: number
selectedCount: number
visibleColCount: number
totalColCount: number
copied: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
copyAll: []
}>()
function updateFilter(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).value)
}
</script>
<template>
<div class="filter-bar">
<div class="filter-input-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
<input
:value="modelValue"
@input="updateFilter"
type="text"
placeholder="Filter results..."
class="filter-input"
/>
<button v-if="modelValue" class="clear-filter" @click="emit('update:modelValue', '')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
</button>
</div>
<div class="filter-actions">
<span class="filter-count">
<template v-if="selectedCount > 0">
{{ selectedCount }} selected
</template>
<template v-else>
{{ rowCount }} of {{ totalCount }} rows
</template>
<template v-if="visibleColCount < totalColCount">
({{ visibleColCount }}/{{ totalColCount }} cols)
</template>
</span>
<button
class="btn-copy-all"
:class="{ copied }"
@click="emit('copyAll')"
:disabled="rowCount === 0 || visibleColCount === 0"
>
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
{{ copied ? 'Copied!' : (selectedCount > 0 ? 'Copy Selected' : 'Copy All') }}
</button>
</div>
</div>
</template>
<style scoped>
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.filter-input-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
max-width: 400px;
padding: 0.5rem 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-muted);
}
.filter-input-wrapper:focus-within {
border-color: var(--accent);
}
.filter-input {
flex: 1;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 0.875rem;
outline: none;
}
.filter-input::placeholder {
color: var(--text-muted);
}
.clear-filter {
padding: 0.25rem;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
}
.clear-filter:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.filter-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.filter-count {
font-size: 0.8rem;
color: var(--text-muted);
}
.btn-copy-all {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background: var(--bg-hover);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-copy-all:hover:not(:disabled) {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-copy-all:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-copy-all.copied {
background: var(--accent);
border-color: var(--accent);
color: white;
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
defineProps<{
columns: string[]
hiddenColumns: Set<string>
}>()
const emit = defineEmits<{
toggleColumn: [column: string]
}>()
</script>
<template>
<div v-if="columns.length > 0" class="query-columns-bar">
<span class="columns-label">Columns:</span>
<div class="query-columns-list">
<span
v-for="col in columns"
:key="col"
class="query-col-toggle"
:class="{ hidden: hiddenColumns.has(col) }"
@click="emit('toggleColumn', col)"
:title="hiddenColumns.has(col) ? 'Click to show' : 'Click to hide'"
>
<svg v-if="!hiddenColumns.has(col)" xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
{{ col }}
</span>
</div>
</div>
</template>
<style scoped>
.query-columns-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
overflow-x: auto;
}
.columns-label {
font-size: 0.75rem;
color: var(--text-muted);
flex-shrink: 0;
}
.query-columns-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.query-col-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
background: var(--bg-tertiary);
border-radius: 4px;
font-size: 0.75rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.query-col-toggle:hover {
background: var(--bg-hover);
}
.query-col-toggle.hidden {
opacity: 0.5;
text-decoration: line-through;
}
</style>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
defineProps<{
modelValue: string
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
execute: []
}>()
function onInput(e: Event) {
emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
}
function onKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'Enter') {
emit('execute')
}
}
</script>
<template>
<div class="query-editor">
<textarea
:value="modelValue"
@input="onInput"
@keydown="onKeydown"
placeholder="SELECT * FROM themes LIMIT 10;"
></textarea>
<div class="query-actions">
<span class="hint">Ctrl+Enter to execute. Only SELECT queries allowed.</span>
<button class="btn-primary" @click="emit('execute')" :disabled="loading">
{{ loading ? 'Running...' : 'Execute' }}
</button>
</div>
</div>
</template>
<style scoped>
.query-editor {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.query-editor textarea {
width: 100%;
min-height: 120px;
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.9rem;
resize: vertical;
}
.query-editor textarea:focus {
outline: none;
border-color: var(--accent);
}
.query-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.hint {
font-size: 0.8rem;
color: var(--text-muted);
}
.btn-primary {
padding: 0.5rem 1.25rem;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import type { TableSchema } from '@/types/database'
defineProps<{
tableName: string
schema: TableSchema[]
hiddenColumns: Set<string>
}>()
const emit = defineEmits<{
toggleColumn: [column: string]
}>()
</script>
<template>
<div class="schema-info">
<div class="schema-header">
<h3>{{ tableName }}</h3>
<span class="schema-hint">Click columns to show/hide</span>
</div>
<div class="schema-columns">
<span
v-for="col in schema"
:key="col.name"
class="schema-col clickable"
:class="{ pk: col.pk, hidden: hiddenColumns.has(col.name) }"
@click="emit('toggleColumn', col.name)"
:title="hiddenColumns.has(col.name) ? 'Click to show' : 'Click to hide'"
>
<svg v-if="!hiddenColumns.has(col.name)" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
{{ col.name }}
<small>{{ col.type }}</small>
</span>
</div>
</div>
</template>
<style scoped>
.schema-info {
padding: 1rem 1.5rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.schema-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.schema-info h3 {
margin: 0;
font-size: 1rem;
color: var(--text-primary);
}
.schema-hint {
font-size: 0.75rem;
color: var(--text-muted);
}
.schema-columns {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.schema-col {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
background: var(--bg-tertiary);
border-radius: 4px;
font-size: 0.8rem;
color: var(--text-secondary);
}
.schema-col.clickable {
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.schema-col.clickable:hover {
background: var(--bg-hover);
}
.schema-col.hidden {
opacity: 0.5;
text-decoration: line-through;
}
.schema-col.pk {
background: var(--accent-muted);
color: var(--accent);
}
.schema-col.pk.hidden {
background: var(--bg-tertiary);
}
.schema-col small {
color: var(--text-muted);
font-size: 0.7rem;
}
</style>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
defineProps<{
currentPage: number
totalPages: number
}>()
const emit = defineEmits<{
changePage: [page: number]
}>()
</script>
<template>
<div v-if="totalPages > 1" class="pagination">
<button @click="emit('changePage', 1)" :disabled="currentPage === 1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/>
</svg>
</button>
<button @click="emit('changePage', currentPage - 1)" :disabled="currentPage === 1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
<button @click="emit('changePage', currentPage + 1)" :disabled="currentPage === totalPages">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
<button @click="emit('changePage', totalPages)" :disabled="currentPage === totalPages">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/>
</svg>
</button>
</div>
</template>
<style scoped>
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
}
.pagination button {
padding: 0.375rem;
background: var(--bg-hover);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.pagination button:hover:not(:disabled) {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
padding: 0 0.75rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
</style>

View File

@@ -0,0 +1,8 @@
export { default as DatabaseSidebar } from './DatabaseSidebar.vue'
export { default as DataTable } from './DataTable.vue'
export { default as FilterBar } from './FilterBar.vue'
export { default as SchemaInfo } from './SchemaInfo.vue'
export { default as QueryEditor } from './QueryEditor.vue'
export { default as QueryColumnsBar } from './QueryColumnsBar.vue'
export { default as DatabaseStats } from './DatabaseStats.vue'
export { default as TablePagination } from './TablePagination.vue'

View File

@@ -0,0 +1,3 @@
export { useDatabaseApi } from './useDatabaseApi'
export { useDataTable, type UseDataTableOptions, type UseDataTableReturn } from './useDataTable'
export { useQueryExecutor } from './useQueryExecutor'

View File

@@ -0,0 +1,158 @@
import { ref, computed, type Ref, type ComputedRef } from 'vue'
export interface UseDataTableOptions {
data: Ref<any[]>
}
export interface UseDataTableReturn {
filter: Ref<string>
hiddenColumns: Ref<Set<string>>
selectedRows: Ref<Set<number>>
copiedCell: Ref<string | null>
copiedAll: Ref<boolean>
filteredData: ComputedRef<any[]>
visibleColumns: ComputedRef<string[]>
allColumns: ComputedRef<string[]>
allRowsSelected: ComputedRef<boolean>
toggleColumn: (column: string) => void
toggleRow: (idx: number) => void
toggleAllRows: () => void
copyCell: (value: any, cellId: string) => Promise<void>
copyAll: () => Promise<void>
reset: () => void
formatValue: (value: any) => string
}
export function useDataTable(options: UseDataTableOptions): UseDataTableReturn {
const { data } = options
const filter = ref('')
const hiddenColumns = ref<Set<string>>(new Set())
const selectedRows = ref<Set<number>>(new Set())
const copiedCell = ref<string | null>(null)
const copiedAll = ref(false)
const allColumns = computed(() => {
if (data.value.length === 0) return []
return Object.keys(data.value[0])
})
const visibleColumns = computed(() => {
return allColumns.value.filter(col => !hiddenColumns.value.has(col))
})
const filteredData = computed(() => {
if (!filter.value.trim()) return data.value
const search = filter.value.toLowerCase()
return data.value.filter(row =>
Object.values(row).some(val =>
String(val).toLowerCase().includes(search)
)
)
})
const allRowsSelected = computed(() => {
if (filteredData.value.length === 0) return false
return filteredData.value.every((_, idx) => selectedRows.value.has(idx))
})
function toggleColumn(column: string) {
if (hiddenColumns.value.has(column)) {
hiddenColumns.value.delete(column)
} else {
hiddenColumns.value.add(column)
}
}
function toggleRow(idx: number) {
if (selectedRows.value.has(idx)) {
selectedRows.value.delete(idx)
} else {
selectedRows.value.add(idx)
}
}
function toggleAllRows() {
if (allRowsSelected.value) {
selectedRows.value.clear()
} else {
filteredData.value.forEach((_, idx) => selectedRows.value.add(idx))
}
}
function formatValue(value: any): string {
if (value === null) return 'NULL'
if (typeof value === 'object') return JSON.stringify(value)
if (typeof value === 'string' && value.length > 100) {
return value.substring(0, 100) + '...'
}
return String(value)
}
async function copyCell(value: any, cellId: string) {
const textToCopy = value === null ? 'NULL' :
typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)
try {
await navigator.clipboard.writeText(textToCopy)
copiedCell.value = cellId
setTimeout(() => {
copiedCell.value = null
}, 1500)
} catch (e) {
console.error('Failed to copy:', e)
}
}
async function copyAll() {
const rowsToUse = selectedRows.value.size > 0
? filteredData.value.filter((_, idx) => selectedRows.value.has(idx))
: filteredData.value
const dataToCopy = rowsToUse.map(row => {
const filtered: Record<string, any> = {}
visibleColumns.value.forEach(col => {
filtered[col] = row[col]
})
return filtered
})
if (dataToCopy.length === 0) return
try {
const jsonText = JSON.stringify(dataToCopy, null, 2)
await navigator.clipboard.writeText(jsonText)
copiedAll.value = true
setTimeout(() => {
copiedAll.value = false
}, 2000)
} catch (e) {
console.error('Failed to copy:', e)
}
}
function reset() {
filter.value = ''
hiddenColumns.value.clear()
selectedRows.value.clear()
}
return {
filter,
hiddenColumns,
selectedRows,
copiedCell,
copiedAll,
filteredData,
visibleColumns,
allColumns,
allRowsSelected,
toggleColumn,
toggleRow,
toggleAllRows,
copyCell,
copyAll,
reset,
formatValue
}
}

View File

@@ -0,0 +1,80 @@
import { ref } from 'vue'
import type { TableInfo, TableSchema, DbStats } from '@/types/database'
const API_BASE = 'http://localhost:4101/api/database'
export function useDatabaseApi() {
const tables = ref<TableInfo[]>([])
const tableSchema = ref<TableSchema[]>([])
const tableData = ref<any[]>([])
const dbStats = ref<DbStats | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const totalRecords = ref(0)
async function fetchTables() {
loading.value = true
error.value = null
try {
const res = await fetch(`${API_BASE}/tables`)
if (!res.ok) throw new Error('Failed to fetch tables')
tables.value = await res.json()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function fetchDbStats() {
try {
const res = await fetch(`${API_BASE}/stats`)
if (!res.ok) throw new Error('Failed to fetch stats')
dbStats.value = await res.json()
} catch (e: any) {
console.error('Error fetching stats:', e)
}
}
async function fetchTableSchema(tableName: string) {
try {
const res = await fetch(`${API_BASE}/tables/${tableName}/schema`)
if (!res.ok) throw new Error('Failed to fetch schema')
tableSchema.value = await res.json()
} catch (e: any) {
console.error('Error fetching schema:', e)
tableSchema.value = []
}
}
async function fetchTableData(tableName: string, page: number, pageSize: number) {
loading.value = true
try {
const offset = (page - 1) * pageSize
const res = await fetch(`${API_BASE}/tables/${tableName}/data?limit=${pageSize}&offset=${offset}`)
if (!res.ok) throw new Error('Failed to fetch data')
const result = await res.json()
tableData.value = result.rows
totalRecords.value = result.total
} catch (e: any) {
console.error('Error fetching data:', e)
tableData.value = []
} finally {
loading.value = false
}
}
return {
tables,
tableSchema,
tableData,
dbStats,
loading,
error,
totalRecords,
fetchTables,
fetchDbStats,
fetchTableSchema,
fetchTableData
}
}

View File

@@ -0,0 +1,53 @@
import { ref } from 'vue'
const API_BASE = 'http://localhost:4101/api/database'
export function useQueryExecutor() {
const queryText = ref('')
const queryResult = ref<any[] | null>(null)
const queryError = ref<string | null>(null)
const queryLoading = ref(false)
async function executeQuery() {
if (!queryText.value.trim()) return
queryLoading.value = true
queryError.value = null
queryResult.value = null
try {
const res = await fetch(`${API_BASE}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: queryText.value })
})
const result = await res.json()
if (!res.ok) {
queryError.value = result.error || 'Query failed'
} else {
queryResult.value = result.rows
}
} catch (e: any) {
queryError.value = e.message
} finally {
queryLoading.value = false
}
}
function reset() {
queryText.value = ''
queryResult.value = null
queryError.value = null
}
return {
queryText,
queryResult,
queryError,
queryLoading,
executeQuery,
reset
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ let fitAddon: FitAddon | null = null
let socket: WebSocket | null = null let socket: WebSocket | null = null
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
const WS_URL = 'ws://localhost:4103' const WS_URL = `ws://${window.location.hostname}:4103`
function initTerminal() { function initTerminal() {
if (!terminalContainer.value) return if (!terminalContainer.value) return

View File

@@ -0,0 +1,595 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useToolsStore } from '@/stores/tools'
import {
getCurrentPage,
isRegistryInitialized,
activateTool,
deactivateTool,
activateCategory,
deactivateCategory,
syncStoreWithActiveTools
} from '@/services/toolRegistry'
import {
ALL_TOOL_METAS,
CATEGORY_INFO,
type ToolCategory,
type ToolMeta
} from '@/services/tools/toolDefinitions'
const toolsStore = useToolsStore()
const { activeTools: activeToolsSet } = storeToRefs(toolsStore)
// State
const currentPage = ref<string | null>(null)
const isInitialized = ref(false)
const refreshInterval = ref<number | null>(null)
const activeTab = ref<'active' | 'all'>('all')
const expandedCategories = ref<Set<string>>(new Set(['global', 'canvas', 'component']))
// Computed - use reactive store
const activeTools = computed(() => Array.from(activeToolsSet.value))
const toolsByCategory = computed(() => {
const categories: Record<string, ToolMeta[]> = {}
for (const category of Object.keys(CATEGORY_INFO)) {
categories[category] = []
}
for (const tool of ALL_TOOL_METAS) {
categories[tool.category].push(tool)
}
// Sort each category by name
for (const category of Object.keys(categories)) {
categories[category].sort((a, b) => a.name.localeCompare(b.name))
}
return categories
})
const totalTools = computed(() => ALL_TOOL_METAS.length)
const totalActive = computed(() => activeTools.value.length)
const totalPinned = computed(() => toolsStore.getPinnedToolNames().length)
function refresh() {
syncStoreWithActiveTools()
currentPage.value = getCurrentPage()
isInitialized.value = isRegistryInitialized()
}
function toggleCategory(category: string) {
if (expandedCategories.value.has(category)) {
expandedCategories.value.delete(category)
} else {
expandedCategories.value.add(category)
}
}
async function handleToggleTool(tool: ToolMeta) {
if (activeTools.value.includes(tool.name)) {
deactivateTool(tool.name)
} else {
await activateTool(tool.name)
}
refresh()
}
async function handleTogglePin(tool: ToolMeta) {
toolsStore.togglePin(tool.name)
// Si pinneamos una tool inactiva, activarla
if (toolsStore.isToolPinned(tool.name) && !activeTools.value.includes(tool.name)) {
await activateTool(tool.name)
}
refresh()
}
async function handleActivateCategory(category: ToolCategory) {
await activateCategory(category)
refresh()
}
function handleDeactivateCategory(category: ToolCategory) {
deactivateCategory(category)
refresh()
}
function isToolActive(name: string): boolean {
return activeTools.value.includes(name)
}
function isToolPinned(name: string): boolean {
return toolsStore.isToolPinned(name)
}
function getCategoryStats(category: string) {
const tools = toolsByCategory.value[category]
if (!tools) return { active: 0, total: 0 }
const activeCount = tools.filter(t => activeTools.value.includes(t.name)).length
return {
active: activeCount,
total: tools.length
}
}
onMounted(() => {
refresh()
refreshInterval.value = window.setInterval(refresh, 2000)
})
onUnmounted(() => {
if (refreshInterval.value) {
window.clearInterval(refreshInterval.value)
}
})
</script>
<template>
<div class="tools-page">
<header class="page-header">
<div class="header-content">
<h1>Tool Registry</h1>
<p class="subtitle">Manage MCP tools activation and persistence</p>
</div>
<button class="refresh-btn" @click="refresh" title="Refresh">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
</svg>
</button>
</header>
<div class="status-bar">
<div class="status-item">
<span class="status-label">Status</span>
<span class="status-value" :class="{ active: isInitialized }">
{{ isInitialized ? 'Ready' : 'Not Initialized' }}
</span>
</div>
<div class="status-item">
<span class="status-label">Current Page</span>
<span class="status-value page">{{ currentPage || 'None' }}</span>
</div>
<div class="status-item">
<span class="status-label">Active</span>
<span class="status-value count">{{ totalActive }} / {{ totalTools }}</span>
</div>
<div class="status-item">
<span class="status-label">Pinned</span>
<span class="status-value pinned">{{ totalPinned }}</span>
</div>
</div>
<div class="tabs">
<button
:class="{ active: activeTab === 'all' }"
@click="activeTab = 'all'"
>
All Tools
</button>
<button
:class="{ active: activeTab === 'active' }"
@click="activeTab = 'active'"
>
Active Only
</button>
</div>
<main class="content">
<div class="categories">
<div
v-for="(info, category) in CATEGORY_INFO"
:key="category"
class="category-card"
:class="{ collapsed: !expandedCategories.has(category) }"
>
<div
class="category-header"
:style="{ '--cat-color': info.color }"
@click="toggleCategory(category)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path :d="info.icon"/>
</svg>
<h3>{{ info.label }}</h3>
<span class="tool-count">
{{ getCategoryStats(category).active }} / {{ getCategoryStats(category).total }}
</span>
<div class="category-actions" @click.stop>
<button
class="cat-btn activate"
@click="handleActivateCategory(category as ToolCategory)"
title="Activate all"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
<button
class="cat-btn deactivate"
@click="handleDeactivateCategory(category as ToolCategory)"
title="Deactivate all"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<svg
class="chevron"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div class="tool-list" v-show="expandedCategories.has(category)">
<template v-for="tool in toolsByCategory[category] || []" :key="tool.name">
<!-- Filter by activeTab -->
<div
v-if="activeTab === 'all' || isToolActive(tool.name)"
class="tool-item"
:class="{ active: isToolActive(tool.name), inactive: !isToolActive(tool.name) }"
>
<div class="tool-info">
<span class="tool-name">{{ tool.name }}</span>
<span class="tool-desc">{{ tool.description }}</span>
</div>
<div class="tool-actions">
<button
class="pin-btn"
:class="{ pinned: isToolPinned(tool.name) }"
@click="handleTogglePin(tool)"
:title="isToolPinned(tool.name) ? 'Unpin' : 'Pin (keep active)'"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 17v5"/>
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v4.76z"/>
</svg>
</button>
<button
class="toggle-btn"
:class="{ active: isToolActive(tool.name) }"
@click="handleToggleTool(tool)"
:disabled="isToolPinned(tool.name)"
:title="isToolActive(tool.name) ? 'Deactivate' : 'Activate'"
>
<svg v-if="isToolActive(tool.name)" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
</svg>
</button>
</div>
</div>
</template>
<!-- Empty state for active tab -->
<div
v-if="activeTab === 'active' && getCategoryStats(category).active === 0"
class="empty-category"
>
No active tools in this category
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<style scoped>
.tools-page {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-primary);
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.header-content h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.subtitle {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--text-secondary);
}
.refresh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.refresh-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.status-bar {
display: flex;
gap: 1.5rem;
padding: 0.75rem 1.5rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.status-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.status-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.status-value {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
}
.status-value.active { color: #10b981; }
.status-value.page { color: #6366f1; font-family: monospace; }
.status-value.count { color: var(--text-primary); }
.status-value.pinned { color: #f59e0b; }
.tabs {
display: flex;
gap: 0.25rem;
padding: 0.5rem 1.5rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.tabs button {
padding: 0.4rem 0.75rem;
background: transparent;
border: none;
border-radius: 5px;
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
}
.tabs button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.tabs button.active {
background: var(--accent-muted);
color: var(--accent);
}
.content {
flex: 1;
overflow: auto;
padding: 1rem 1.5rem;
}
.categories {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.category-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.category-header {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 1rem;
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
color: var(--cat-color);
cursor: pointer;
user-select: none;
}
.category-card.collapsed .category-header {
border-bottom: none;
}
.category-header h3 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
flex: 1;
}
.tool-count {
font-size: 0.7rem;
font-weight: 500;
padding: 0.15rem 0.4rem;
background: var(--bg-secondary);
border-radius: 8px;
color: var(--text-secondary);
}
.category-actions {
display: flex;
gap: 0.25rem;
}
.cat-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.cat-btn:hover {
background: var(--bg-hover);
}
.cat-btn.activate:hover {
color: #10b981;
border-color: #10b981;
}
.cat-btn.deactivate:hover {
color: #ef4444;
border-color: #ef4444;
}
.chevron {
color: var(--text-muted);
transition: transform 0.2s;
}
.category-card.collapsed .chevron {
transform: rotate(-90deg);
}
.tool-list {
padding: 0.25rem 0;
}
.tool-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
transition: background 0.15s;
}
.tool-item:hover {
background: var(--bg-hover);
}
.tool-item.inactive {
opacity: 0.6;
}
.tool-item.inactive:hover {
opacity: 1;
}
.tool-info {
display: flex;
flex-direction: column;
gap: 0.1rem;
flex: 1;
min-width: 0;
}
.tool-name {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-primary);
}
.tool-desc {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tool-actions {
display: flex;
gap: 0.35rem;
margin-left: 0.5rem;
}
.pin-btn,
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.pin-btn:hover {
color: #f59e0b;
border-color: #f59e0b;
}
.pin-btn.pinned {
color: #f59e0b;
background: rgba(245, 158, 11, 0.15);
border-color: #f59e0b;
}
.toggle-btn:hover {
color: var(--text-primary);
border-color: var(--text-secondary);
}
.toggle-btn.active {
color: #10b981;
background: rgba(16, 185, 129, 0.15);
border-color: #10b981;
}
.toggle-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-category {
padding: 0.75rem 1rem;
font-size: 0.75rem;
color: var(--text-muted);
font-style: italic;
}
</style>

View File

@@ -48,6 +48,11 @@ const router = createRouter({
path: '/terminal', path: '/terminal',
name: 'terminal', name: 'terminal',
component: () => import('../pages/TerminalPage.vue') component: () => import('../pages/TerminalPage.vue')
},
{
path: '/tools',
name: 'tools',
component: () => import('../pages/ToolsPage.vue')
} }
] ]
}) })

View File

@@ -1,123 +1,159 @@
import { clearAllTools } from './webmcp' /**
import { * Tool Registry - Single source of truth for MCP tool management
registerCanvasTools, *
unregisterCanvasTools, * All tool registration/unregistration MUST go through this module.
CANVAS_TOOLS * Other modules should NOT directly use webmcp registration functions.
} from './tools/canvasTools' */
import {
registerComponentTools,
unregisterComponentTools,
COMPONENT_TOOLS
} from './tools/componentTools'
import {
registerThemeTools,
unregisterThemeTools,
THEME_TOOLS
} from './tools/themeTools'
import {
registerGlobalTools,
setRouter,
GLOBAL_TOOLS
} from './tools/globalTools'
import {
registerProjectCanvasTools,
unregisterProjectCanvasTools,
PROJECT_CANVAS_TOOLS
} from './tools/projectCanvasTools'
import {
registerDatabaseTools,
unregisterDatabaseTools,
DATABASE_TOOLS
} from './tools/databaseTools'
import {
registerSourceCodeTools,
unregisterSourceCodeTools,
SOURCE_CODE_TOOLS
} from './tools/sourceCodeTools'
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal' import {
initWebMCP,
getRegisteredTools as getWebMCPTools,
clearAllTools as clearWebMCPTools
} from './webmcp'
import { useToolsStore } from '../stores/tools'
import {
createGlobalHandlers,
createCanvasHandlers,
createComponentHandlers,
createThemeHandlers,
createDatabaseHandlers,
createProjectCanvasHandlers,
createSourceCodeHandlers,
createTerminalHandlers,
type ToolConfig
} from './tools/handlers'
import { setRouter } from './tools/handlers/globalHandlers'
import { setGiteaCredentials, clearGiteaCredentials } from './tools/handlers/sourceCodeHandlers'
import { ALL_TOOL_METAS, getAllToolNames, type ToolCategory } from './tools/toolDefinitions'
interface PageToolSet { export type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools'
register: () => void
unregister: () => void // Internal webmcp functions (not exported for external use)
toolNames: string[] let webmcpInstance: any = null
const registeredToolsSet = new Set<string>()
async function internalRegisterTool(config: ToolConfig): Promise<boolean> {
if (!webmcpInstance) {
webmcpInstance = await initWebMCP()
} }
const pageTools: Record<PageName, PageToolSet> = { if (registeredToolsSet.has(config.name)) {
home: { return false // Already registered
register: () => {
registerCanvasTools()
registerComponentTools()
registerProjectCanvasTools()
},
unregister: () => {
unregisterCanvasTools()
unregisterComponentTools()
unregisterProjectCanvasTools()
},
toolNames: [...CANVAS_TOOLS, ...COMPONENT_TOOLS, ...PROJECT_CANVAS_TOOLS]
},
canvas: {
register: () => {
registerCanvasTools()
registerComponentTools()
},
unregister: () => {
unregisterCanvasTools()
unregisterComponentTools()
},
toolNames: [...CANVAS_TOOLS, ...COMPONENT_TOOLS]
},
'project-canvas': {
register: () => {
registerCanvasTools()
registerComponentTools()
registerProjectCanvasTools()
},
unregister: () => {
unregisterCanvasTools()
unregisterComponentTools()
unregisterProjectCanvasTools()
},
toolNames: [...CANVAS_TOOLS, ...COMPONENT_TOOLS, ...PROJECT_CANVAS_TOOLS]
},
projects: {
register: registerProjectCanvasTools,
unregister: unregisterProjectCanvasTools,
toolNames: PROJECT_CANVAS_TOOLS
},
components: {
register: registerComponentTools,
unregister: unregisterComponentTools,
toolNames: COMPONENT_TOOLS
},
themes: {
register: registerThemeTools,
unregister: unregisterThemeTools,
toolNames: THEME_TOOLS
},
database: {
register: registerDatabaseTools,
unregister: unregisterDatabaseTools,
toolNames: DATABASE_TOOLS
},
source: {
register: registerSourceCodeTools,
unregister: unregisterSourceCodeTools,
toolNames: SOURCE_CODE_TOOLS
},
terminal: {
register: () => {},
unregister: () => {},
toolNames: []
} }
webmcpInstance.registerTool(config.name, config.description, config.schema, config.handler)
registeredToolsSet.add(config.name)
console.log(`[ToolRegistry] Registered: ${config.name}`)
return true
}
function internalUnregisterTool(name: string): boolean {
if (!webmcpInstance || !registeredToolsSet.has(name)) {
return false
}
webmcpInstance.unregisterTool(name)
registeredToolsSet.delete(name)
console.log(`[ToolRegistry] Unregistered: ${name}`)
return true
}
function internalClearAllTools() {
if (!webmcpInstance) return
for (const name of registeredToolsSet) {
webmcpInstance.unregisterTool(name)
}
console.log(`[ToolRegistry] Cleared ${registeredToolsSet.size} tools`)
registeredToolsSet.clear()
}
// Tool configurations cache
let toolConfigsCache: Map<string, ToolConfig> | null = null
function getToolConfigs(): Map<string, ToolConfig> {
if (toolConfigsCache) return toolConfigsCache
toolConfigsCache = new Map()
// Create callbacks for global handlers
const toolManagementCallbacks = {
getRegisteredTools: () => Array.from(registeredToolsSet),
getAllToolNames: () => getAllToolNames(),
activateTool: async (name: string) => {
const config = toolConfigsCache?.get(name)
if (!config) return false
const result = await internalRegisterTool(config)
syncStoreWithActiveTools()
return result
},
deactivateTool: (name: string) => {
const toolsStore = useToolsStore()
if (toolsStore.isToolPinned(name)) return false
const result = internalUnregisterTool(name)
syncStoreWithActiveTools()
return result
},
togglePin: (name: string) => {
const toolsStore = useToolsStore()
toolsStore.togglePin(name)
},
isToolPinned: (name: string) => {
const toolsStore = useToolsStore()
return toolsStore.isToolPinned(name)
}
}
// Create all handlers
const allHandlers = [
...createGlobalHandlers(toolManagementCallbacks),
...createCanvasHandlers(),
...createComponentHandlers(),
...createThemeHandlers(),
...createDatabaseHandlers(),
...createProjectCanvasHandlers(),
...createSourceCodeHandlers(),
...createTerminalHandlers()
]
for (const config of allHandlers) {
toolConfigsCache.set(config.name, config)
}
return toolConfigsCache
}
// Category to tool names mapping
const categoryTools: Record<ToolCategory, string[]> = {
global: ['get_current_page', 'navigate_to', 'list_available_tools', 'activate_tool', 'deactivate_tool', 'pin_tool'],
canvas: ['render_html', 'render_vue_component'],
component: ['save_vue_component', 'load_vue_component', 'list_vue_components', 'delete_vue_component'],
theme: ['get_design_tokens', 'get_active_theme', 'set_theme_variable', 'save_theme', 'list_themes', 'switch_theme', 'reset_theme'],
database: ['list_tables', 'get_table_schema', 'get_table_data', 'get_database_stats', 'execute_query'],
source: ['get_repo_info', 'list_repo_files', 'read_repo_file', 'search_repo_code'],
project: ['list_canvases', 'create_canvas', 'get_canvas', 'update_canvas', 'delete_canvas', 'clone_canvas', 'add_component_to_canvas', 'remove_component_from_canvas', 'get_canvas_components'],
terminal: ['terminal_open', 'terminal_close', 'terminal_toggle', 'terminal_move', 'terminal_resize']
}
// Page to categories mapping
const pageCategories: Record<PageName, ToolCategory[]> = {
home: ['global', 'canvas', 'component', 'project', 'terminal'],
canvas: ['global', 'canvas', 'component', 'terminal'],
'project-canvas': ['global', 'canvas', 'component', 'project', 'terminal'],
projects: ['global', 'project', 'terminal'],
components: ['global', 'component', 'terminal'],
themes: ['global', 'theme', 'terminal'],
database: ['global', 'database', 'terminal'],
source: ['global', 'source', 'terminal'],
terminal: ['global', 'terminal'],
tools: ['global', 'terminal']
} }
let currentPage: PageName | null = null let currentPage: PageName | null = null
let isInitialized = false let isInitialized = false
/** /**
* Inicializa el registry con el router de Vue * Initialize the tool registry with Vue router
*/ */
export function initToolRegistry(router: any) { export function initToolRegistry(router: any) {
setRouter(router) setRouter(router)
@@ -125,80 +161,197 @@ export function initToolRegistry(router: any) {
} }
/** /**
* Activa las tools para una página específica. * Set Gitea credentials for source code tools
* Desregistra las tools de otras páginas primero.
*/ */
export function activatePageTools(pageName: PageName) { export function setSourceCodeCredentials(creds: any) {
setGiteaCredentials(creds)
}
/**
* Clear Gitea credentials
*/
export function clearSourceCodeCredentials() {
clearGiteaCredentials()
}
/**
* Activate tools for a specific page
*/
export async function activatePageTools(pageName: PageName) {
if (!isInitialized) { if (!isInitialized) {
console.warn('[ToolRegistry] Not initialized. Call initToolRegistry first.') console.warn('[ToolRegistry] Not initialized')
return return
} }
// Si ya estamos en esta página, no hacer nada
if (currentPage === pageName) { if (currentPage === pageName) {
console.log(`[ToolRegistry] Already on page "${pageName}", skipping`) console.log(`[ToolRegistry] Already on "${pageName}", skipping`)
return return
} }
console.log(`[ToolRegistry] Switching from "${currentPage}" to "${pageName}"`) console.log(`[ToolRegistry] Switching from "${currentPage}" to "${pageName}"`)
// Desregistrar tools de la página anterior const toolsStore = useToolsStore()
if (currentPage && pageTools[currentPage]) { const pinnedTools = toolsStore.getPinnedToolNames()
pageTools[currentPage].unregister() const configs = getToolConfigs()
// Get tools for old and new page
const oldCategories = currentPage ? pageCategories[currentPage] : []
const newCategories = pageCategories[pageName]
const oldTools = new Set(oldCategories.flatMap(cat => categoryTools[cat]))
const newTools = new Set(newCategories.flatMap(cat => categoryTools[cat]))
// Unregister old tools (except pinned)
for (const tool of oldTools) {
if (!newTools.has(tool) && !pinnedTools.includes(tool)) {
internalUnregisterTool(tool)
}
} }
// Registrar tools de la nueva página // Register new tools
if (pageTools[pageName]) { for (const toolName of newTools) {
pageTools[pageName].register() const config = configs.get(toolName)
if (config) {
await internalRegisterTool(config)
}
} }
// Asegurar que las tools globales estén registradas // Ensure pinned tools are registered
registerGlobalTools() for (const toolName of pinnedTools) {
const config = configs.get(toolName)
if (config && !registeredToolsSet.has(toolName)) {
await internalRegisterTool(config)
}
}
currentPage = pageName currentPage = pageName
syncStoreWithActiveTools()
console.log(`[ToolRegistry] Page "${pageName}" tools activated`) console.log(`[ToolRegistry] Page "${pageName}" tools activated`)
} }
/** /**
* Inicializa las tools para un refresh de página. * Initialize tools on page refresh
* Limpia todo y registra las tools correctas.
*/ */
export function initToolsOnRefresh(pageName: PageName) { export async function initToolsOnRefresh(pageName: PageName) {
if (!isInitialized) { if (!isInitialized) {
console.warn('[ToolRegistry] Not initialized. Call initToolRegistry first.') console.warn('[ToolRegistry] Not initialized')
return return
} }
console.log(`[ToolRegistry] Initializing on refresh for page "${pageName}"`) console.log(`[ToolRegistry] Initializing on refresh for "${pageName}"`)
// Limpiar todas las tools existentes internalClearAllTools()
clearAllTools()
// Reset current page tracking
currentPage = null currentPage = null
await activatePageTools(pageName)
// Activar tools de la página actual
activatePageTools(pageName)
} }
/** /**
* Obtiene el nombre de la página actual * Activate a single tool by name
*/
export async function activateTool(toolName: string): Promise<boolean> {
const configs = getToolConfigs()
const config = configs.get(toolName)
if (!config) {
console.warn(`[ToolRegistry] Tool "${toolName}" not found`)
return false
}
const result = await internalRegisterTool(config)
syncStoreWithActiveTools()
return result
}
/**
* Deactivate a single tool by name
*/
export function deactivateTool(toolName: string): boolean {
const toolsStore = useToolsStore()
if (toolsStore.isToolPinned(toolName)) {
console.warn(`[ToolRegistry] Cannot deactivate pinned tool "${toolName}"`)
return false
}
const result = internalUnregisterTool(toolName)
syncStoreWithActiveTools()
return result
}
/**
* Activate all tools in a category
*/
export async function activateCategory(category: ToolCategory) {
const configs = getToolConfigs()
const tools = categoryTools[category] || []
for (const toolName of tools) {
const config = configs.get(toolName)
if (config) {
await internalRegisterTool(config)
}
}
syncStoreWithActiveTools()
}
/**
* Deactivate all tools in a category (respecting pinned)
*/
export function deactivateCategory(category: ToolCategory) {
const toolsStore = useToolsStore()
const tools = categoryTools[category] || []
for (const toolName of tools) {
if (!toolsStore.isToolPinned(toolName)) {
internalUnregisterTool(toolName)
}
}
syncStoreWithActiveTools()
}
/**
* Sync the store with currently active tools
*/
export function syncStoreWithActiveTools() {
const toolsStore = useToolsStore()
toolsStore.setActiveTools(Array.from(registeredToolsSet))
}
/**
* Get current page name
*/ */
export function getCurrentPage(): PageName | null { export function getCurrentPage(): PageName | null {
return currentPage return currentPage
} }
/** /**
* Obtiene los nombres de las tools para una página * Get tool names for a page
*/ */
export function getPageToolNames(pageName: PageName): string[] { export function getPageToolNames(pageName: PageName): string[] {
return [...(pageTools[pageName]?.toolNames || []), ...GLOBAL_TOOLS] const categories = pageCategories[pageName] || []
return categories.flatMap(cat => categoryTools[cat])
} }
/** /**
* Verifica si el registry está inicializado * Check if registry is initialized
*/ */
export function isRegistryInitialized(): boolean { export function isRegistryInitialized(): boolean {
return isInitialized return isInitialized
} }
/**
* Get all tool metadata
*/
export function getAllToolMetas() {
return ALL_TOOL_METAS
}
/**
* Get registered tools (for internal use)
*/
export function getRegisteredTools(): string[] {
return Array.from(registeredToolsSet)
}

View File

@@ -1,146 +0,0 @@
import { useCanvasStore } from '../../stores/canvas'
import { registerTool, unregisterTools } from '../webmcp'
import {
renderInlineComponent,
type VueComponentDefinition
} from '../dynamicComponents'
export const CANVAS_TOOLS = ['render_html', 'render_vue_component']
function getCanvasContainer() {
return document.getElementById('canvas-content')
}
function removePlaceholder(container: HTMLElement) {
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
}
function emitComponentRendered(args: any) {
window.dispatchEvent(new CustomEvent('vue-component-rendered', {
detail: {
id: args.id,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports
}
}))
}
export function registerCanvasTools() {
const canvasStore = useCanvasStore()
// render_html
registerTool(
'render_html',
'Renderiza HTML en el canvas. Soporta <script> tags que se ejecutan automáticamente y <style> tags.',
{
type: 'object',
properties: {
html: {
type: 'string',
description: 'El código HTML a renderizar (puede incluir <script> y <style> tags)'
},
mode: {
type: 'string',
enum: ['replace', 'append', 'prepend'],
description: 'Modo: replace (reemplaza), append (agrega al final), prepend (al inicio)'
}
},
required: ['html']
},
(args: { html: string; mode?: string }) => {
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const mode = args.mode || 'replace'
if (mode === 'replace') {
container.innerHTML = args.html
} else if (mode === 'append') {
container.insertAdjacentHTML('beforeend', args.html)
} else if (mode === 'prepend') {
container.insertAdjacentHTML('afterbegin', args.html)
}
// Ejecutar scripts inline
const scripts = container.querySelectorAll('script')
scripts.forEach((oldScript) => {
const newScript = document.createElement('script')
Array.from(oldScript.attributes).forEach(attr => {
newScript.setAttribute(attr.name, attr.value)
})
newScript.textContent = oldScript.textContent
oldScript.parentNode?.replaceChild(newScript, oldScript)
})
canvasStore.addToHistory({ tool: 'render_html', args, timestamp: Date.now() })
return 'HTML renderizado'
}
)
// render_vue_component
registerTool(
'render_vue_component',
'Renderiza un componente Vue 3 completo con acceso a ref, reactive, computed, watch, Pinia stores, etc.',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID único del componente' },
name: { type: 'string', description: 'Nombre del componente (ej: MyCounter)' },
template: { type: 'string', description: 'Template HTML del componente con sintaxis Vue' },
setup: { type: 'string', description: 'Código de la función setup (debe retornar un objeto con las propiedades reactivas)' },
style: { type: 'string', description: 'CSS del componente (opcional)' },
props: { type: 'array', items: { type: 'string' }, description: 'Lista de props que acepta el componente' },
imports: { type: 'array', items: { type: 'string' }, description: 'Funciones de Vue a importar: ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick, h' },
componentProps: { type: 'object', description: 'Valores para las props del componente' },
mode: { type: 'string', enum: ['replace', 'append'], description: 'replace: limpia el canvas, append: agrega al final' }
},
required: ['id', 'name', 'template']
},
(args: {
id: string
name: string
template: string
setup?: string
style?: string
props?: string[]
imports?: string[]
componentProps?: Record<string, any>
mode?: string
}) => {
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const definition: VueComponentDefinition = {
id: args.id,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports || ['ref', 'reactive', 'computed']
}
const isAppend = args.mode === 'append'
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
;(window as any).__vueComponentUnmount = result.unmount
emitComponentRendered(args)
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
return `Componente Vue "${args.name}" renderizado correctamente`
}
)
}
export function unregisterCanvasTools() {
unregisterTools(CANVAS_TOOLS)
}

View File

@@ -1,147 +0,0 @@
import { useCanvasStore } from '../../stores/canvas'
import { registerTool, unregisterTools } from '../webmcp'
import {
renderInlineComponent,
componentsApi,
type VueComponentDefinition
} from '../dynamicComponents'
export const COMPONENT_TOOLS = [
'save_vue_component',
'load_vue_component',
'list_vue_components',
'delete_vue_component'
]
function getCanvasContainer() {
return document.getElementById('canvas-content')
}
function removePlaceholder(container: HTMLElement) {
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
}
export function registerComponentTools() {
const canvasStore = useCanvasStore()
// save_vue_component
registerTool(
'save_vue_component',
'Guarda un componente Vue en la base de datos para reutilizarlo después',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID único del componente (se genera automáticamente si no se proporciona)' },
name: { type: 'string', description: 'Nombre del componente' },
template: { type: 'string', description: 'Template HTML del componente' },
setup: { type: 'string', description: 'Código de la función setup' },
style: { type: 'string', description: 'CSS del componente' },
props: { type: 'array', items: { type: 'string' }, description: 'Lista de props' },
imports: { type: 'array', items: { type: 'string' }, description: 'Funciones de Vue necesarias' }
},
required: ['name', 'template']
},
async (args: Omit<VueComponentDefinition, 'id'> & { id?: string }) => {
try {
const result = await componentsApi.save({
id: args.id || `comp-${Date.now()}`,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports
})
canvasStore.addToHistory({ tool: 'save_vue_component', args, timestamp: Date.now() })
return `Componente "${args.name}" guardado con ID: ${result.id}`
} catch (e: any) {
return `Error al guardar: ${e.message}`
}
}
)
// load_vue_component
registerTool(
'load_vue_component',
'Carga un componente Vue guardado desde la base de datos y lo renderiza',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID del componente a cargar' },
componentProps: { type: 'object', description: 'Props para pasar al componente' },
mode: { type: 'string', enum: ['replace', 'append'], description: 'replace: limpia el canvas, append: agrega al final' }
},
required: ['id']
},
async (args: { id: string; componentProps?: Record<string, any>; mode?: string }) => {
try {
const definition = await componentsApi.getById(args.id)
if (!definition) {
return `Error: Componente con ID "${args.id}" no encontrado`
}
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const isAppend = args.mode === 'append'
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
;(window as any).__vueComponentUnmount = result.unmount
canvasStore.addToHistory({ tool: 'load_vue_component', args, timestamp: Date.now() })
return `Componente "${definition.name}" cargado y renderizado`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// list_vue_components
registerTool(
'list_vue_components',
'Lista todos los componentes Vue guardados en la base de datos',
{
type: 'object',
properties: {}
},
async () => {
try {
const components = await componentsApi.getAll()
if (components.length === 0) {
return 'No hay componentes guardados'
}
const list = components.map(c => `- ${c.id}: ${c.name}`).join('\n')
return `Componentes guardados:\n${list}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// delete_vue_component
registerTool(
'delete_vue_component',
'Elimina un componente Vue de la base de datos',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID del componente a eliminar' }
},
required: ['id']
},
async (args: { id: string }) => {
try {
await componentsApi.delete(args.id)
return `Componente "${args.id}" eliminado`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
}
export function unregisterComponentTools() {
unregisterTools(COMPONENT_TOOLS)
}

View File

@@ -1,231 +0,0 @@
import { registerTool, unregisterTools } from '../webmcp'
export const DATABASE_TOOLS = [
'list_tables',
'get_table_schema',
'get_table_data',
'get_database_stats',
'execute_query'
]
const API_BASE = 'http://localhost:4101'
export function registerDatabaseTools() {
// list_tables
registerTool(
'list_tables',
'Lista todas las tablas de la base de datos SQLite con su conteo de registros',
{
type: 'object',
properties: {}
},
async () => {
try {
const res = await fetch(`${API_BASE}/api/database/tables`)
if (!res.ok) throw new Error('Failed to fetch tables')
const tables = await res.json()
if (tables.length === 0) {
return 'No hay tablas en la base de datos'
}
const tableList = tables.map((t: any) => ` - ${t.name}: ${t.count} registros`).join('\n')
const total = tables.reduce((sum: number, t: any) => sum + t.count, 0)
return `Tablas en la base de datos (${tables.length}):\n\n${tableList}\n\nTotal de registros: ${total}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// get_table_schema
registerTool(
'get_table_schema',
'Obtiene el esquema (columnas y tipos) de una tabla',
{
type: 'object',
properties: {
table: {
type: 'string',
description: 'Nombre de la tabla'
}
},
required: ['table']
},
async (args: { table: string }) => {
try {
const res = await fetch(`${API_BASE}/api/database/tables/${args.table}/schema`)
if (!res.ok) {
if (res.status === 404) return `Tabla "${args.table}" no encontrada`
throw new Error('Failed to fetch schema')
}
const schema = await res.json()
if (schema.length === 0) {
return `La tabla "${args.table}" no tiene columnas definidas`
}
const columns = schema.map((col: any) => {
const flags = []
if (col.pk) flags.push('PRIMARY KEY')
if (col.notnull) flags.push('NOT NULL')
const flagStr = flags.length > 0 ? ` (${flags.join(', ')})` : ''
return ` - ${col.name}: ${col.type}${flagStr}`
}).join('\n')
return `Esquema de la tabla "${args.table}":\n\n${columns}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// get_table_data
registerTool(
'get_table_data',
'Obtiene los datos de una tabla con paginacion',
{
type: 'object',
properties: {
table: {
type: 'string',
description: 'Nombre de la tabla'
},
limit: {
type: 'number',
description: 'Numero de registros a retornar (default: 20, max: 100)'
},
offset: {
type: 'number',
description: 'Registros a saltar para paginacion (default: 0)'
}
},
required: ['table']
},
async (args: { table: string; limit?: number; offset?: number }) => {
try {
const limit = Math.min(args.limit || 20, 100)
const offset = args.offset || 0
const res = await fetch(
`${API_BASE}/api/database/tables/${args.table}/data?limit=${limit}&offset=${offset}`
)
if (!res.ok) {
if (res.status === 404) return `Tabla "${args.table}" no encontrada`
throw new Error('Failed to fetch data')
}
const result = await res.json()
if (result.rows.length === 0) {
return `La tabla "${args.table}" no tiene registros`
}
// Format as readable table
const rows = result.rows.map((row: any, idx: number) => {
const entries = Object.entries(row).map(([k, v]) => {
let value = v
if (typeof v === 'string' && v.length > 50) {
value = v.substring(0, 50) + '...'
} else if (typeof v === 'object') {
value = JSON.stringify(v).substring(0, 50) + '...'
}
return `${k}: ${value}`
}).join(', ')
return `[${offset + idx + 1}] ${entries}`
}).join('\n')
return `Datos de "${args.table}" (${offset + 1}-${offset + result.rows.length} de ${result.total}):\n\n${rows}\n\nUsa offset=${offset + limit} para ver mas registros`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// get_database_stats
registerTool(
'get_database_stats',
'Obtiene estadisticas generales de la base de datos',
{
type: 'object',
properties: {}
},
async () => {
try {
const res = await fetch(`${API_BASE}/api/database/stats`)
if (!res.ok) throw new Error('Failed to fetch stats')
const stats = await res.json()
return `Estadisticas de la base de datos:\n\n` +
` Tamano: ${stats.size}\n` +
` Tablas: ${stats.tables}\n` +
` Registros totales: ${stats.totalRecords}\n\n` +
`Desglose por tabla:\n` +
stats.breakdown.map((t: any) => ` - ${t.name}: ${t.count} registros`).join('\n')
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// execute_query
registerTool(
'execute_query',
'Ejecuta una consulta SQL SELECT en la base de datos (solo lectura)',
{
type: 'object',
properties: {
query: {
type: 'string',
description: 'Consulta SQL (solo SELECT permitido)'
}
},
required: ['query']
},
async (args: { query: string }) => {
try {
const res = await fetch(`${API_BASE}/api/database/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: args.query })
})
const result = await res.json()
if (!res.ok) {
return `Error en la consulta: ${result.error}`
}
if (result.rows.length === 0) {
return 'La consulta no retorno resultados'
}
// Format results
const columns = Object.keys(result.rows[0])
const header = columns.join(' | ')
const separator = columns.map(() => '---').join(' | ')
const rows = result.rows.slice(0, 50).map((row: any) => {
return columns.map(col => {
let value = row[col]
if (value === null) return 'NULL'
if (typeof value === 'object') return JSON.stringify(value)
if (typeof value === 'string' && value.length > 40) {
return value.substring(0, 40) + '...'
}
return String(value)
}).join(' | ')
}).join('\n')
const truncated = result.rows.length > 50 ? `\n\n... y ${result.rows.length - 50} filas mas` : ''
return `Resultados (${result.rows.length} filas):\n\n${header}\n${separator}\n${rows}${truncated}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
}
export function unregisterDatabaseTools() {
unregisterTools(DATABASE_TOOLS)
}

View File

@@ -1,108 +0,0 @@
import { registerTool, unregisterTools, getRegisteredTools } from '../webmcp'
export const GLOBAL_TOOLS = [
'get_current_page',
'navigate_to',
'list_available_tools'
]
let routerInstance: any = null
export function setRouter(router: any) {
routerInstance = router
}
export function registerGlobalTools() {
// get_current_page
registerTool(
'get_current_page',
'Obtiene la página actualmente activa en Agent UI',
{
type: 'object',
properties: {}
},
() => {
if (!routerInstance) {
return 'Error: Router no disponible'
}
const route = routerInstance.currentRoute.value
const pageInfo: Record<string, string> = {
canvas: 'Canvas - Renderiza componentes Vue y HTML dinámicamente',
components: 'Components - Gestiona componentes guardados en la base de datos',
themes: 'Themes - Editor visual de temas y design tokens'
}
const pageName = route.name as string || 'unknown'
const description = pageInfo[pageName] || 'Página desconocida'
return `Página actual: ${pageName}\n` +
`Ruta: ${route.path}\n` +
`Descripción: ${description}\n\n` +
`Herramientas disponibles en esta página:\n${getRegisteredTools().map(t => ` - ${t}`).join('\n')}`
}
)
// navigate_to
registerTool(
'navigate_to',
'Navega a una página específica de Agent UI',
{
type: 'object',
properties: {
page: {
type: 'string',
enum: ['canvas', 'dynamic-canvas', 'components', 'themes'],
description: 'Página a la que navegar'
}
},
required: ['page']
},
async (args: { page: string }) => {
if (!routerInstance) {
return 'Error: Router no disponible'
}
const routes: Record<string, string> = {
canvas: '/',
'dynamic-canvas': '/dynamic/canvas',
components: '/components',
themes: '/themes'
}
const path = routes[args.page]
if (!path) {
return `Error: Página "${args.page}" no válida. Opciones: canvas, dynamic-canvas, components, themes`
}
try {
await routerInstance.push(path)
return `Navegando a ${args.page} (${path})\n\n` +
`Nota: Las herramientas MCP se actualizarán automáticamente para esta página.`
} catch (e: any) {
return `Error al navegar: ${e.message}`
}
}
)
// list_available_tools
registerTool(
'list_available_tools',
'Lista todas las herramientas MCP actualmente disponibles',
{
type: 'object',
properties: {}
},
() => {
const tools = getRegisteredTools()
if (tools.length === 0) {
return 'No hay herramientas registradas'
}
return `Herramientas MCP disponibles (${tools.length}):\n${tools.map(t => ` - ${t}`).join('\n')}`
}
)
}
export function unregisterGlobalTools() {
unregisterTools(GLOBAL_TOOLS)
}

View File

@@ -0,0 +1,141 @@
import type { ToolConfig } from './index'
import { useCanvasStore } from '../../../stores/canvas'
import {
renderInlineComponent,
type VueComponentDefinition
} from '../../dynamicComponents'
function getCanvasContainer() {
return document.getElementById('canvas-content')
}
function removePlaceholder(container: HTMLElement) {
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
}
function emitComponentRendered(args: any) {
window.dispatchEvent(new CustomEvent('vue-component-rendered', {
detail: {
id: args.id,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports
}
}))
}
export function createCanvasHandlers(): ToolConfig[] {
return [
{
name: 'render_html',
description: 'Renderiza HTML en el canvas. Soporta <script> y <style> tags.',
category: 'canvas',
schema: {
type: 'object',
properties: {
html: {
type: 'string',
description: 'El codigo HTML a renderizar'
},
mode: {
type: 'string',
enum: ['replace', 'append', 'prepend'],
description: 'Modo: replace, append, prepend'
}
},
required: ['html']
},
handler: (args: { html: string; mode?: string }) => {
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const mode = args.mode || 'replace'
if (mode === 'replace') {
container.innerHTML = args.html
} else if (mode === 'append') {
container.insertAdjacentHTML('beforeend', args.html)
} else if (mode === 'prepend') {
container.insertAdjacentHTML('afterbegin', args.html)
}
// Ejecutar scripts inline
const scripts = container.querySelectorAll('script')
scripts.forEach((oldScript) => {
const newScript = document.createElement('script')
Array.from(oldScript.attributes).forEach(attr => {
newScript.setAttribute(attr.name, attr.value)
})
newScript.textContent = oldScript.textContent
oldScript.parentNode?.replaceChild(newScript, oldScript)
})
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'render_html', args, timestamp: Date.now() })
return 'HTML renderizado'
}
},
{
name: 'render_vue_component',
description: 'Renderiza un componente Vue 3 completo con ref, reactive, computed, etc.',
category: 'canvas',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID unico del componente' },
name: { type: 'string', description: 'Nombre del componente' },
template: { type: 'string', description: 'Template HTML con sintaxis Vue' },
setup: { type: 'string', description: 'Codigo de la funcion setup' },
style: { type: 'string', description: 'CSS del componente' },
props: { type: 'array', items: { type: 'string' }, description: 'Lista de props' },
imports: { type: 'array', items: { type: 'string' }, description: 'Funciones de Vue a importar' },
componentProps: { type: 'object', description: 'Valores para las props' },
mode: { type: 'string', enum: ['replace', 'append'], description: 'Modo de renderizado' }
},
required: ['id', 'name', 'template']
},
handler: (args: {
id: string
name: string
template: string
setup?: string
style?: string
props?: string[]
imports?: string[]
componentProps?: Record<string, any>
mode?: string
}) => {
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const definition: VueComponentDefinition = {
id: args.id,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports || ['ref', 'reactive', 'computed']
}
const isAppend = args.mode === 'append'
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
;(window as any).__vueComponentUnmount = result.unmount
emitComponentRendered(args)
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
return `Componente Vue "${args.name}" renderizado`
}
}
]
}

View File

@@ -0,0 +1,135 @@
import type { ToolConfig } from './index'
import { useCanvasStore } from '../../../stores/canvas'
import {
renderInlineComponent,
componentsApi,
type VueComponentDefinition
} from '../../dynamicComponents'
function getCanvasContainer() {
return document.getElementById('canvas-content')
}
function removePlaceholder(container: HTMLElement) {
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) placeholder.remove()
}
export function createComponentHandlers(): ToolConfig[] {
return [
{
name: 'save_vue_component',
description: 'Guarda un componente Vue en la base de datos',
category: 'component',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID unico del componente' },
name: { type: 'string', description: 'Nombre del componente' },
template: { type: 'string', description: 'Template HTML' },
setup: { type: 'string', description: 'Codigo de setup' },
style: { type: 'string', description: 'CSS' },
props: { type: 'array', items: { type: 'string' }, description: 'Props' },
imports: { type: 'array', items: { type: 'string' }, description: 'Imports de Vue' }
},
required: ['name', 'template']
},
handler: async (args: Omit<VueComponentDefinition, 'id'> & { id?: string }) => {
try {
const result = await componentsApi.save({
id: args.id || `comp-${Date.now()}`,
name: args.name,
template: args.template,
setup: args.setup,
style: args.style,
props: args.props,
imports: args.imports
})
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'save_vue_component', args, timestamp: Date.now() })
return `Componente "${args.name}" guardado con ID: ${result.id}`
} catch (e: any) {
return `Error al guardar: ${e.message}`
}
}
},
{
name: 'load_vue_component',
description: 'Carga un componente guardado y lo renderiza',
category: 'component',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del componente' },
componentProps: { type: 'object', description: 'Props para el componente' },
mode: { type: 'string', enum: ['replace', 'append'], description: 'Modo' }
},
required: ['id']
},
handler: async (args: { id: string; componentProps?: Record<string, any>; mode?: string }) => {
try {
const definition = await componentsApi.getById(args.id)
if (!definition) {
return `Error: Componente "${args.id}" no encontrado`
}
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const isAppend = args.mode === 'append'
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend)
;(window as any).__vueComponentUnmount = result.unmount
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'load_vue_component', args, timestamp: Date.now() })
return `Componente "${definition.name}" cargado y renderizado`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'list_vue_components',
description: 'Lista todos los componentes guardados',
category: 'component',
schema: {
type: 'object',
properties: {}
},
handler: async () => {
try {
const components = await componentsApi.getAll()
if (components.length === 0) {
return 'No hay componentes guardados'
}
const list = components.map(c => `- ${c.id}: ${c.name}`).join('\n')
return `Componentes guardados:\n${list}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'delete_vue_component',
description: 'Elimina un componente de la base de datos',
category: 'component',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del componente' }
},
required: ['id']
},
handler: async (args: { id: string }) => {
try {
await componentsApi.delete(args.id)
return `Componente "${args.id}" eliminado`
} catch (e: any) {
return `Error: ${e.message}`
}
}
}
]
}

View File

@@ -0,0 +1,186 @@
import type { ToolConfig } from './index'
const API_BASE = 'http://localhost:4101'
export function createDatabaseHandlers(): ToolConfig[] {
return [
{
name: 'list_tables',
description: 'Lista todas las tablas de la base de datos',
category: 'database',
schema: { type: 'object', properties: {} },
handler: async () => {
try {
const res = await fetch(`${API_BASE}/api/database/tables`)
if (!res.ok) throw new Error('Failed to fetch tables')
const tables = await res.json()
if (tables.length === 0) return 'No hay tablas'
const tableList = tables.map((t: any) => ` - ${t.name}: ${t.count} registros`).join('\n')
const total = tables.reduce((sum: number, t: any) => sum + t.count, 0)
return `Tablas (${tables.length}):\n\n${tableList}\n\nTotal: ${total} registros`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'get_table_schema',
description: 'Obtiene el esquema de una tabla',
category: 'database',
schema: {
type: 'object',
properties: {
table: { type: 'string', description: 'Nombre de la tabla' }
},
required: ['table']
},
handler: async (args: { table: string }) => {
try {
const res = await fetch(`${API_BASE}/api/database/tables/${args.table}/schema`)
if (!res.ok) {
if (res.status === 404) return `Tabla "${args.table}" no encontrada`
throw new Error('Failed to fetch schema')
}
const schema = await res.json()
if (schema.length === 0) return `La tabla "${args.table}" no tiene columnas`
const columns = schema.map((col: any) => {
const flags = []
if (col.pk) flags.push('PK')
if (col.notnull) flags.push('NOT NULL')
const flagStr = flags.length > 0 ? ` (${flags.join(', ')})` : ''
return ` - ${col.name}: ${col.type}${flagStr}`
}).join('\n')
return `Esquema de "${args.table}":\n\n${columns}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'get_table_data',
description: 'Obtiene datos de una tabla',
category: 'database',
schema: {
type: 'object',
properties: {
table: { type: 'string', description: 'Nombre de la tabla' },
limit: { type: 'number', description: 'Limite de registros (max 100)' },
offset: { type: 'number', description: 'Offset para paginacion' }
},
required: ['table']
},
handler: async (args: { table: string; limit?: number; offset?: number }) => {
try {
const limit = Math.min(args.limit || 20, 100)
const offset = args.offset || 0
const res = await fetch(
`${API_BASE}/api/database/tables/${args.table}/data?limit=${limit}&offset=${offset}`
)
if (!res.ok) {
if (res.status === 404) return `Tabla "${args.table}" no encontrada`
throw new Error('Failed to fetch data')
}
const result = await res.json()
if (result.rows.length === 0) return `La tabla "${args.table}" no tiene registros`
const rows = result.rows.map((row: any, idx: number) => {
const entries = Object.entries(row).map(([k, v]) => {
let value = v
if (typeof v === 'string' && v.length > 50) {
value = v.substring(0, 50) + '...'
} else if (typeof v === 'object') {
value = JSON.stringify(v).substring(0, 50) + '...'
}
return `${k}: ${value}`
}).join(', ')
return `[${offset + idx + 1}] ${entries}`
}).join('\n')
return `Datos de "${args.table}" (${offset + 1}-${offset + result.rows.length} de ${result.total}):\n\n${rows}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'get_database_stats',
description: 'Obtiene estadisticas de la base de datos',
category: 'database',
schema: { type: 'object', properties: {} },
handler: async () => {
try {
const res = await fetch(`${API_BASE}/api/database/stats`)
if (!res.ok) throw new Error('Failed to fetch stats')
const stats = await res.json()
return `Estadisticas:\n\n` +
` Tamano: ${stats.size}\n` +
` Tablas: ${stats.tables}\n` +
` Registros: ${stats.totalRecords}\n\n` +
`Desglose:\n` +
stats.breakdown.map((t: any) => ` - ${t.name}: ${t.count}`).join('\n')
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'execute_query',
description: 'Ejecuta una consulta SQL SELECT',
category: 'database',
schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Consulta SQL (solo SELECT)' }
},
required: ['query']
},
handler: async (args: { query: string }) => {
try {
const res = await fetch(`${API_BASE}/api/database/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: args.query })
})
const result = await res.json()
if (!res.ok) {
return `Error: ${result.error}`
}
if (result.rows.length === 0) return 'Sin resultados'
const columns = Object.keys(result.rows[0])
const header = columns.join(' | ')
const separator = columns.map(() => '---').join(' | ')
const rows = result.rows.slice(0, 50).map((row: any) => {
return columns.map(col => {
let value = row[col]
if (value === null) return 'NULL'
if (typeof value === 'object') return JSON.stringify(value)
if (typeof value === 'string' && value.length > 40) {
return value.substring(0, 40) + '...'
}
return String(value)
}).join(' | ')
}).join('\n')
const truncated = result.rows.length > 50 ? `\n\n... y ${result.rows.length - 50} filas mas` : ''
return `Resultados (${result.rows.length}):\n\n${header}\n${separator}\n${rows}${truncated}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
}
]
}

View File

@@ -0,0 +1,213 @@
import type { ToolConfig } from './index'
let routerInstance: any = null
export function setRouter(router: any) {
routerInstance = router
}
export interface ToolManagementCallbacks {
getRegisteredTools: () => string[]
getAllToolNames: () => string[]
activateTool: (name: string) => Promise<boolean>
deactivateTool: (name: string) => boolean
togglePin: (name: string) => void
isToolPinned: (name: string) => boolean
}
export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolConfig[] {
const { getRegisteredTools, getAllToolNames, activateTool, deactivateTool, togglePin, isToolPinned } = callbacks
const allToolNames = getAllToolNames()
return [
{
name: 'get_current_page',
description: 'Obtiene la pagina actualmente activa en Agent UI',
category: 'global',
schema: {
type: 'object',
properties: {}
},
handler: () => {
if (!routerInstance) {
return 'Error: Router no disponible'
}
const route = routerInstance.currentRoute.value
const pageInfo: Record<string, string> = {
home: 'Home - Canvas principal con componentes',
canvas: 'Canvas - Renderiza componentes Vue y HTML',
components: 'Components - Gestiona componentes guardados',
themes: 'Themes - Editor visual de temas',
database: 'Database - Explorador de base de datos',
source: 'Source - Navegador de codigo fuente',
projects: 'Projects - Gestiona proyectos',
terminal: 'Terminal - Consola de comandos',
tools: 'Tools - Gestion de herramientas MCP'
}
const pageName = route.name as string || 'unknown'
const description = pageInfo[pageName] || 'Pagina desconocida'
return `Pagina actual: ${pageName}\n` +
`Ruta: ${route.path}\n` +
`Descripcion: ${description}\n\n` +
`Herramientas disponibles:\n${getRegisteredTools().map(t => ` - ${t}`).join('\n')}`
}
},
{
name: 'navigate_to',
description: 'Navega a una pagina especifica de Agent UI',
category: 'global',
schema: {
type: 'object',
properties: {
page: {
type: 'string',
enum: ['home', 'canvas', 'components', 'themes', 'database', 'source', 'projects', 'terminal', 'tools'],
description: 'Pagina a la que navegar'
}
},
required: ['page']
},
handler: async (args: { page: string }) => {
if (!routerInstance) {
return 'Error: Router no disponible'
}
const routes: Record<string, string> = {
home: '/',
canvas: '/dynamic/canvas',
components: '/components',
themes: '/themes',
database: '/database',
source: '/source',
projects: '/projects',
terminal: '/terminal',
tools: '/tools'
}
const path = routes[args.page]
if (!path) {
return `Error: Pagina "${args.page}" no valida. Opciones: ${Object.keys(routes).join(', ')}`
}
try {
await routerInstance.push(path)
return `Navegando a ${args.page} (${path})`
} catch (e: any) {
return `Error al navegar: ${e.message}`
}
}
},
{
name: 'list_available_tools',
description: 'Lista todas las herramientas MCP actualmente disponibles',
category: 'global',
schema: {
type: 'object',
properties: {}
},
handler: () => {
const tools = getRegisteredTools()
if (tools.length === 0) {
return 'No hay herramientas registradas'
}
return `Herramientas MCP disponibles (${tools.length}):\n${tools.map(t => ` - ${t}`).join('\n')}`
}
},
{
name: 'activate_tool',
description: 'Activa una herramienta MCP para que este disponible',
category: 'global',
schema: {
type: 'object',
properties: {
tool_name: {
type: 'string',
enum: allToolNames,
description: 'Nombre de la herramienta a activar'
}
},
required: ['tool_name']
},
handler: async (args: { tool_name: string }) => {
const activeTools = getRegisteredTools()
if (activeTools.includes(args.tool_name)) {
return `La herramienta "${args.tool_name}" ya esta activa`
}
const success = await activateTool(args.tool_name)
if (success) {
return `Herramienta "${args.tool_name}" activada correctamente`
} else {
return `Error: No se pudo activar "${args.tool_name}". Verifica que el nombre sea correcto.`
}
}
},
{
name: 'deactivate_tool',
description: 'Desactiva una herramienta MCP. Si esta pinneada, la despinea primero.',
category: 'global',
schema: {
type: 'object',
properties: {
tool_name: {
type: 'string',
enum: allToolNames,
description: 'Nombre de la herramienta a desactivar'
}
},
required: ['tool_name']
},
handler: (args: { tool_name: string }) => {
const activeTools = getRegisteredTools()
if (!activeTools.includes(args.tool_name)) {
return `La herramienta "${args.tool_name}" no esta activa`
}
// Si esta pinneada, despinear primero
if (isToolPinned(args.tool_name)) {
togglePin(args.tool_name)
}
const success = deactivateTool(args.tool_name)
if (success) {
return `Herramienta "${args.tool_name}" desactivada correctamente`
} else {
return `Error: No se pudo desactivar "${args.tool_name}"`
}
}
},
{
name: 'pin_tool',
description: 'Pinnea una herramienta MCP. Las herramientas pinneadas permanecen activas al cambiar de pagina.',
category: 'global',
schema: {
type: 'object',
properties: {
tool_name: {
type: 'string',
enum: allToolNames,
description: 'Nombre de la herramienta a pinnear'
}
},
required: ['tool_name']
},
handler: async (args: { tool_name: string }) => {
if (isToolPinned(args.tool_name)) {
return `La herramienta "${args.tool_name}" ya esta pinneada`
}
togglePin(args.tool_name)
// Asegurar que este activa
const activeTools = getRegisteredTools()
if (!activeTools.includes(args.tool_name)) {
await activateTool(args.tool_name)
}
return `Herramienta "${args.tool_name}" pinneada. Permanecera activa al cambiar de pagina.`
}
}
]
}

View File

@@ -0,0 +1,24 @@
/**
* Centralized tool handlers registry
* All tool handlers are defined here and exported for the toolRegistry
*/
export { createGlobalHandlers } from './globalHandlers'
export { createCanvasHandlers } from './canvasHandlers'
export { createComponentHandlers } from './componentHandlers'
export { createThemeHandlers } from './themeHandlers'
export { createDatabaseHandlers } from './databaseHandlers'
export { createProjectCanvasHandlers } from './projectCanvasHandlers'
export { createSourceCodeHandlers } from './sourceCodeHandlers'
export { createTerminalHandlers, setTerminalControls } from './terminalHandlers'
export type { TerminalControls } from './terminalHandlers'
export type ToolHandler = (args: any) => string | Promise<string>
export interface ToolConfig {
name: string
description: string
category: 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project' | 'terminal'
schema: object
handler: ToolHandler
}

View File

@@ -0,0 +1,223 @@
import type { ToolConfig } from './index'
import { useProjectCanvasStore } from '../../../stores/projectCanvas'
export function createProjectCanvasHandlers(): ToolConfig[] {
return [
{
name: 'list_canvases',
description: 'Lista todos los canvas disponibles',
category: 'project',
schema: {
type: 'object',
properties: {
type: { type: 'string', enum: ['all', 'project', 'system'], description: 'Filtrar por tipo' }
}
},
handler: async (args: { type?: string }) => {
const store = useProjectCanvasStore()
await store.fetchCanvases()
let canvases = store.canvases
if (args.type === 'project') {
canvases = store.projectCanvases
} else if (args.type === 'system') {
canvases = store.systemCanvases
}
return JSON.stringify(canvases.map(c => ({
id: c.id,
name: c.name,
type: c.type,
description: c.description
})), null, 2)
}
},
{
name: 'create_canvas',
description: 'Crea un nuevo canvas',
category: 'project',
schema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Nombre del canvas' },
description: { type: 'string', description: 'Descripcion' },
theme_id: { type: 'string', description: 'ID del tema' },
config: { type: 'object', description: 'Configuracion' }
},
required: ['name']
},
handler: async (args: { name: string; description?: string; theme_id?: string; config?: object }) => {
const store = useProjectCanvasStore()
const id = await store.createCanvas({
name: args.name,
description: args.description,
theme_id: args.theme_id,
config: args.config as any,
type: 'project'
})
if (id) {
return `Canvas "${args.name}" creado. ID: ${id}`
}
return `Error: ${store.error}`
}
},
{
name: 'get_canvas',
description: 'Obtiene detalles de un canvas',
category: 'project',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas' }
},
required: ['id']
},
handler: async (args: { id: string }) => {
const store = useProjectCanvasStore()
const canvas = await store.fetchCanvasById(args.id)
if (!canvas) {
return `Canvas "${args.id}" no encontrado`
}
return JSON.stringify(canvas, null, 2)
}
},
{
name: 'update_canvas',
description: 'Actualiza un canvas existente',
category: 'project',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas' },
name: { type: 'string', description: 'Nuevo nombre' },
description: { type: 'string', description: 'Nueva descripcion' },
theme_id: { type: 'string', description: 'Nuevo tema' },
config: { type: 'object', description: 'Nueva configuracion' }
},
required: ['id']
},
handler: async (args: { id: string; name?: string; description?: string; theme_id?: string; config?: object }) => {
const store = useProjectCanvasStore()
const { id, ...data } = args
const success = await store.updateCanvas(id, data as any)
if (success) {
return `Canvas "${id}" actualizado`
}
return `Error: ${store.error}`
}
},
{
name: 'delete_canvas',
description: 'Elimina un canvas',
category: 'project',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas' }
},
required: ['id']
},
handler: async (args: { id: string }) => {
const store = useProjectCanvasStore()
const success = await store.deleteCanvas(args.id)
if (success) {
return `Canvas "${args.id}" eliminado`
}
return `Error: ${store.error}`
}
},
{
name: 'clone_canvas',
description: 'Clona un canvas existente',
category: 'project',
schema: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas a clonar' },
name: { type: 'string', description: 'Nombre para el nuevo canvas' }
},
required: ['id']
},
handler: async (args: { id: string; name?: string }) => {
const store = useProjectCanvasStore()
const newId = await store.cloneCanvas(args.id, args.name)
if (newId) {
return `Canvas clonado. Nuevo ID: ${newId}`
}
return `Error: ${store.error}`
}
},
{
name: 'add_component_to_canvas',
description: 'Agrega un componente a un canvas',
category: 'project',
schema: {
type: 'object',
properties: {
canvas_id: { type: 'string', description: 'ID del canvas' },
component_id: { type: 'string', description: 'ID del componente' },
props: { type: 'object', description: 'Props para el componente' },
position: { type: 'number', description: 'Posicion' }
},
required: ['canvas_id', 'component_id']
},
handler: async (args: { canvas_id: string; component_id: string; props?: object; position?: number }) => {
const store = useProjectCanvasStore()
const success = await store.addComponentToCanvas(
args.canvas_id,
args.component_id,
args.props as Record<string, any>,
args.position
)
if (success) {
return `Componente agregado al canvas`
}
return 'Error al agregar componente'
}
},
{
name: 'remove_component_from_canvas',
description: 'Remueve un componente de un canvas',
category: 'project',
schema: {
type: 'object',
properties: {
canvas_id: { type: 'string', description: 'ID del canvas' },
component_id: { type: 'string', description: 'ID del componente' }
},
required: ['canvas_id', 'component_id']
},
handler: async (args: { canvas_id: string; component_id: string }) => {
const store = useProjectCanvasStore()
const success = await store.removeComponentFromCanvas(args.canvas_id, args.component_id)
if (success) {
return `Componente removido del canvas`
}
return 'Error al remover componente'
}
},
{
name: 'get_canvas_components',
description: 'Obtiene los componentes de un canvas',
category: 'project',
schema: {
type: 'object',
properties: {
canvas_id: { type: 'string', description: 'ID del canvas' }
},
required: ['canvas_id']
},
handler: async (args: { canvas_id: string }) => {
const store = useProjectCanvasStore()
await store.fetchCanvasComponents(args.canvas_id)
return JSON.stringify(store.activeCanvasComponents, null, 2)
}
}
]
}

View File

@@ -0,0 +1,238 @@
import type { ToolConfig } from './index'
const API_BASE = 'http://localhost:4101'
// Store credentials in memory
let giteaCredentials: {
giteaUrl: string
username: string
password: string
owner: string
repo: string
branch: string
} | null = null
export function setGiteaCredentials(creds: typeof giteaCredentials) {
giteaCredentials = creds
}
export function clearGiteaCredentials() {
giteaCredentials = null
}
export function createSourceCodeHandlers(): ToolConfig[] {
return [
{
name: 'get_repo_info',
description: 'Obtiene info del repositorio Gitea',
category: 'source',
schema: { type: 'object', properties: {} },
handler: async () => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en Source Code.'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/repo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(giteaCredentials)
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
const repo = data.repo
return `Repositorio: ${repo.owner.login}/${repo.name}\n` +
`Descripcion: ${repo.description || 'Sin descripcion'}\n` +
`Rama default: ${repo.default_branch}\n` +
`Stars: ${repo.stars_count}\n` +
`Forks: ${repo.forks_count}\n` +
`Ramas: ${data.branches.join(', ')}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'list_repo_files',
description: 'Lista archivos del repositorio',
category: 'source',
schema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Ruta dentro del repositorio' }
}
},
handler: async (args: { path?: string }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/tree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...giteaCredentials, path: args.path || '' })
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
const path = args.path || '/'
if (data.tree.length === 0) return `No hay archivos en ${path}`
const folders = data.tree.filter((f: any) => f.type === 'dir')
const files = data.tree.filter((f: any) => f.type === 'file')
let result = `Contenido de ${path}:\n\n`
if (folders.length > 0) {
result += `Carpetas (${folders.length}):\n`
result += folders.map((f: any) => ` [DIR] ${f.name}/`).join('\n')
result += '\n\n'
}
if (files.length > 0) {
result += `Archivos (${files.length}):\n`
result += files.map((f: any) => ` ${f.name}`).join('\n')
}
return result
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'read_repo_file',
description: 'Lee contenido de un archivo',
category: 'source',
schema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Ruta del archivo' },
lines: { type: 'number', description: 'Lineas maximas (default: 100)' }
},
required: ['path']
},
handler: async (args: { path: string; lines?: number }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...giteaCredentials, path: args.path })
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
let content = data.content
const maxLines = args.lines || 100
const lines = content.split('\n')
if (lines.length > maxLines) {
content = lines.slice(0, maxLines).join('\n')
content += `\n\n... (${lines.length - maxLines} lineas mas)`
}
return `Archivo: ${args.path}\nTamano: ${data.size} bytes\n\n${content}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'search_repo_code',
description: 'Busca codigo en el repositorio',
category: 'source',
schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Texto a buscar' },
path: { type: 'string', description: 'Ruta donde buscar' },
extension: { type: 'string', description: 'Extension de archivos' }
},
required: ['query']
},
handler: async (args: { query: string; path?: string; extension?: string }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea'
}
try {
const treeRes = await fetch(`${API_BASE}/api/gitea/tree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...giteaCredentials, path: args.path || '' })
})
if (!treeRes.ok) return 'Error al obtener lista de archivos'
const treeData = await treeRes.json()
const files = treeData.tree.filter((f: any) => {
if (f.type !== 'file') return false
if (args.extension) {
return f.name.endsWith(`.${args.extension}`)
}
return true
})
const results: string[] = []
const maxFiles = 10
for (const file of files.slice(0, maxFiles)) {
try {
const fileRes = await fetch(`${API_BASE}/api/gitea/file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...giteaCredentials, path: file.path })
})
if (fileRes.ok) {
const fileData = await fileRes.json()
const lines = fileData.content.split('\n')
const matches: string[] = []
lines.forEach((line: string, idx: number) => {
if (line.toLowerCase().includes(args.query.toLowerCase())) {
matches.push(` L${idx + 1}: ${line.trim().substring(0, 80)}`)
}
})
if (matches.length > 0) {
results.push(`${file.path}:\n${matches.slice(0, 5).join('\n')}`)
}
}
} catch (e) {
// Skip file errors
}
}
if (results.length === 0) {
return `No se encontro "${args.query}"`
}
return `Busqueda: "${args.query}"\n\n${results.join('\n\n')}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
}
]
}

View File

@@ -0,0 +1,119 @@
/**
* Terminal UI control handlers
* Controls the FloatingTerminal window (open, close, move, resize)
*/
import type { ToolConfig } from './index'
export interface TerminalControls {
open: (x?: number, y?: number) => void
close: () => void
toggle: () => void
move: (x: number, y: number) => void
resize: (width: number, height: number) => void
getState: () => { isOpen: boolean; position: { x: number; y: number }; size: { w: number; h: number } }
}
// Global reference to terminal controls (set by App.vue)
let terminalControls: TerminalControls | null = null
export function setTerminalControls(controls: TerminalControls) {
terminalControls = controls
;(window as any).__terminalControls = controls
}
export function getTerminalControls(): TerminalControls | null {
return terminalControls
}
export function createTerminalHandlers(): ToolConfig[] {
return [
{
name: 'terminal_open',
description: 'Abre la ventana flotante del terminal. Opcionalmente en una posicion especifica.',
category: 'terminal',
schema: {
type: 'object',
properties: {
x: { type: 'number', description: 'Posicion X en pixels (opcional)' },
y: { type: 'number', description: 'Posicion Y en pixels (opcional)' }
}
},
handler: (args: { x?: number; y?: number }) => {
if (!terminalControls) return 'Error: Terminal controls not initialized'
terminalControls.open(args.x, args.y)
const pos = args.x !== undefined && args.y !== undefined
? ` en posicion (${args.x}, ${args.y})`
: ''
return `Terminal abierta${pos}`
}
},
{
name: 'terminal_close',
description: 'Cierra la ventana flotante del terminal.',
category: 'terminal',
schema: {
type: 'object',
properties: {}
},
handler: () => {
if (!terminalControls) return 'Error: Terminal controls not initialized'
terminalControls.close()
return 'Terminal cerrada'
}
},
{
name: 'terminal_toggle',
description: 'Alterna el estado de la ventana del terminal (abre si esta cerrada, cierra si esta abierta).',
category: 'terminal',
schema: {
type: 'object',
properties: {}
},
handler: () => {
if (!terminalControls) return 'Error: Terminal controls not initialized'
const wasOpen = terminalControls.getState().isOpen
terminalControls.toggle()
return wasOpen ? 'Terminal cerrada' : 'Terminal abierta'
}
},
{
name: 'terminal_move',
description: 'Mueve la ventana del terminal a una posicion especifica en pixels.',
category: 'terminal',
schema: {
type: 'object',
properties: {
x: { type: 'number', description: 'Posicion X en pixels' },
y: { type: 'number', description: 'Posicion Y en pixels' }
},
required: ['x', 'y']
},
handler: (args: { x: number; y: number }) => {
if (!terminalControls) return 'Error: Terminal controls not initialized'
terminalControls.move(args.x, args.y)
return `Terminal movida a (${args.x}, ${args.y})`
}
},
{
name: 'terminal_resize',
description: 'Cambia el tamano de la ventana del terminal.',
category: 'terminal',
schema: {
type: 'object',
properties: {
width: { type: 'number', description: 'Ancho en pixels (min 400)' },
height: { type: 'number', description: 'Alto en pixels (min 250)' }
},
required: ['width', 'height']
},
handler: (args: { width: number; height: number }) => {
if (!terminalControls) return 'Error: Terminal controls not initialized'
const w = Math.max(400, args.width)
const h = Math.max(250, args.height)
terminalControls.resize(w, h)
return `Terminal redimensionada a ${w}x${h}`
}
}
]
}

View File

@@ -0,0 +1,211 @@
import type { ToolConfig } from './index'
import { useThemeStore } from '../../../stores/theme'
export function createThemeHandlers(): ToolConfig[] {
return [
{
name: 'get_design_tokens',
description: 'Obtiene los design tokens del tema activo',
category: 'theme',
schema: {
type: 'object',
properties: {
category: {
type: 'string',
enum: ['all', 'colors', 'text', 'accent', 'semantic', 'spacing', 'typography', 'effects'],
description: 'Categoria de tokens'
}
}
},
handler: async (args: { category?: string }) => {
const themeStore = useThemeStore()
const theme = themeStore.activeTheme
if (!theme) {
return 'No hay tema activo'
}
const category = args.category || 'all'
const variables = theme.variables
if (category !== 'all' && variables[category as keyof typeof variables]) {
const categoryVars = variables[category as keyof typeof variables]
const tokenList = Object.entries(categoryVars)
.map(([name, value]) => `--${name}: ${value}`)
.join('\n')
return `Design Tokens - ${category.toUpperCase()}:\n\n${tokenList}`
}
const allTokens = Object.entries(variables)
.map(([cat, vars]) => {
const tokenList = Object.entries(vars as Record<string, string>)
.map(([name, value]) => ` --${name}: ${value}`)
.join('\n')
return `[${cat.toUpperCase()}]\n${tokenList}`
})
.join('\n\n')
return `Design Tokens del tema "${theme.name}":\n\n${allTokens}`
}
},
{
name: 'get_active_theme',
description: 'Obtiene info del tema activo',
category: 'theme',
schema: { type: 'object', properties: {} },
handler: () => {
const themeStore = useThemeStore()
const theme = themeStore.activeTheme
if (!theme) return 'No hay tema activo'
return `Tema activo: "${theme.name}"\n` +
`ID: ${theme.id}\n` +
`Sistema: ${theme.is_system ? 'Si' : 'No'}\n` +
`Default: ${theme.is_default ? 'Si' : 'No'}`
}
},
{
name: 'set_theme_variable',
description: 'Modifica una variable CSS del tema',
category: 'theme',
schema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Nombre de la variable sin --' },
value: { type: 'string', description: 'Nuevo valor' }
},
required: ['name', 'value']
},
handler: (args: { name: string; value: string }) => {
const themeStore = useThemeStore()
const root = document.documentElement
const varName = args.name.startsWith('--') ? args.name : `--${args.name}`
const keyName = args.name.startsWith('--') ? args.name.slice(2) : args.name
const currentValue = getComputedStyle(root).getPropertyValue(varName).trim()
root.style.setProperty(varName, args.value)
if (themeStore.activeTheme) {
const variables = themeStore.activeTheme.variables
for (const category of Object.keys(variables) as (keyof typeof variables)[]) {
if (keyName in variables[category]) {
themeStore.updateVariable(category, keyName, args.value)
break
}
}
}
return `Variable ${varName} cambiada:\n Anterior: ${currentValue || '(no definida)'}\n Nuevo: ${args.value}`
}
},
{
name: 'save_theme',
description: 'Guarda el tema actual',
category: 'theme',
schema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Nombre del tema' },
description: { type: 'string', description: 'Descripcion' },
setAsDefault: { type: 'boolean', description: 'Establecer como default' }
},
required: ['name']
},
handler: async (args: { name: string; description?: string; setAsDefault?: boolean }) => {
const themeStore = useThemeStore()
const variablesToSave = themeStore.previewTheme || themeStore.activeTheme?.variables
if (!variablesToSave) {
return 'Error: No hay variables para guardar'
}
const result = await themeStore.saveTheme({
name: args.name,
description: args.description || `Tema creado el ${new Date().toLocaleString()}`,
variables: variablesToSave,
metadata: { author: 'Claude', version: '1.0', base: themeStore.activeTheme?.id || null }
})
if (args.setAsDefault && result.id) {
await themeStore.setDefaultTheme(result.id)
return `Tema "${args.name}" guardado y establecido como default. ID: ${result.id}`
}
return `Tema "${args.name}" guardado. ID: ${result.id}`
}
},
{
name: 'list_themes',
description: 'Lista todos los temas disponibles',
category: 'theme',
schema: { type: 'object', properties: {} },
handler: async () => {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
const themes = themeStore.themes
if (themes.length === 0) return 'No hay temas disponibles'
const systemThemes = themes.filter(t => t.is_system)
const userThemes = themes.filter(t => !t.is_system)
let result = `Temas disponibles (${themes.length}):\n\n`
if (systemThemes.length > 0) {
result += `[SISTEMA]\n`
result += systemThemes.map(t => ` - ${t.name}${t.is_default ? ' [DEFAULT]' : ''}`).join('\n')
result += '\n\n'
}
if (userThemes.length > 0) {
result += `[PERSONALIZADOS]\n`
result += userThemes.map(t => ` - ${t.name}${t.is_default ? ' [DEFAULT]' : ''}`).join('\n')
}
return result
}
},
{
name: 'switch_theme',
description: 'Cambia al tema especificado',
category: 'theme',
schema: {
type: 'object',
properties: {
theme: { type: 'string', description: 'Nombre o ID del tema' }
},
required: ['theme']
},
handler: async (args: { theme: string }) => {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
const theme = themeStore.themes.find(t =>
t.id === args.theme || t.name.toLowerCase() === args.theme.toLowerCase()
)
if (!theme) {
return `Tema "${args.theme}" no encontrado`
}
themeStore.selectTheme(theme)
return `Tema cambiado a "${theme.name}"`
}
},
{
name: 'reset_theme',
description: 'Descarta cambios no guardados',
category: 'theme',
schema: { type: 'object', properties: {} },
handler: () => {
const themeStore = useThemeStore()
if (!themeStore.previewTheme) {
return 'No hay cambios pendientes'
}
themeStore.resetPreview()
return 'Cambios descartados'
}
}
]
}

View File

@@ -1,246 +0,0 @@
import { registerTool, unregisterTools } from '../webmcp'
import { useProjectCanvasStore } from '../../stores/projectCanvas'
export const PROJECT_CANVAS_TOOLS = [
'list_canvases',
'create_canvas',
'get_canvas',
'update_canvas',
'delete_canvas',
'clone_canvas',
'add_component_to_canvas',
'remove_component_from_canvas',
'get_canvas_components'
]
export function registerProjectCanvasTools() {
const store = useProjectCanvasStore()
// list_canvases
registerTool(
'list_canvases',
'Lista todos los canvas disponibles (proyectos, sistema)',
{
type: 'object',
properties: {
type: {
type: 'string',
enum: ['all', 'project', 'system'],
description: 'Filtrar por tipo de canvas'
}
}
},
async (args: { type?: string }) => {
await store.fetchCanvases()
let canvases = store.canvases
if (args.type === 'project') {
canvases = store.projectCanvases
} else if (args.type === 'system') {
canvases = store.systemCanvases
}
return JSON.stringify(canvases.map(c => ({
id: c.id,
name: c.name,
type: c.type,
description: c.description,
is_system: c.is_system
})), null, 2)
}
)
// create_canvas
registerTool(
'create_canvas',
'Crea un nuevo project canvas',
{
type: 'object',
properties: {
name: { type: 'string', description: 'Nombre del canvas' },
description: { type: 'string', description: 'Descripcion del canvas' },
theme_id: { type: 'string', description: 'ID del tema a usar (opcional)' },
config: {
type: 'object',
description: 'Configuracion del canvas (layout, settings, permissions)'
}
},
required: ['name']
},
async (args: { name: string; description?: string; theme_id?: string; config?: object }) => {
const id = await store.createCanvas({
name: args.name,
description: args.description,
theme_id: args.theme_id,
config: args.config as any,
type: 'project'
})
if (id) {
return `Canvas "${args.name}" creado con ID: ${id}`
}
return `Error al crear canvas: ${store.error}`
}
)
// get_canvas
registerTool(
'get_canvas',
'Obtiene los detalles de un canvas por ID',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas' }
},
required: ['id']
},
async (args: { id: string }) => {
const canvas = await store.fetchCanvasById(args.id)
if (!canvas) {
return `Canvas con ID "${args.id}" no encontrado`
}
return JSON.stringify(canvas, null, 2)
}
)
// update_canvas
registerTool(
'update_canvas',
'Actualiza un canvas existente',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas a actualizar' },
name: { type: 'string', description: 'Nuevo nombre' },
description: { type: 'string', description: 'Nueva descripcion' },
theme_id: { type: 'string', description: 'Nuevo tema' },
config: { type: 'object', description: 'Nueva configuracion' }
},
required: ['id']
},
async (args: { id: string; name?: string; description?: string; theme_id?: string; config?: object }) => {
const { id, ...data } = args
const success = await store.updateCanvas(id, data as any)
if (success) {
return `Canvas "${id}" actualizado`
}
return `Error al actualizar canvas: ${store.error}`
}
)
// delete_canvas
registerTool(
'delete_canvas',
'Elimina un canvas (no se pueden eliminar canvas del sistema)',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas a eliminar' }
},
required: ['id']
},
async (args: { id: string }) => {
const success = await store.deleteCanvas(args.id)
if (success) {
return `Canvas "${args.id}" eliminado`
}
return `Error al eliminar canvas: ${store.error}`
}
)
// clone_canvas
registerTool(
'clone_canvas',
'Clona un canvas existente (incluyendo sus componentes)',
{
type: 'object',
properties: {
id: { type: 'string', description: 'ID del canvas a clonar' },
name: { type: 'string', description: 'Nombre para el nuevo canvas (opcional)' }
},
required: ['id']
},
async (args: { id: string; name?: string }) => {
const newId = await store.cloneCanvas(args.id, args.name)
if (newId) {
return `Canvas clonado con nuevo ID: ${newId}`
}
return `Error al clonar canvas: ${store.error}`
}
)
// add_component_to_canvas
registerTool(
'add_component_to_canvas',
'Agrega un componente guardado a un canvas',
{
type: 'object',
properties: {
canvas_id: { type: 'string', description: 'ID del canvas' },
component_id: { type: 'string', description: 'ID del componente a agregar' },
props: { type: 'object', description: 'Props para el componente en este canvas' },
position: { type: 'number', description: 'Posicion del componente (orden de renderizado)' }
},
required: ['canvas_id', 'component_id']
},
async (args: { canvas_id: string; component_id: string; props?: object; position?: number }) => {
const success = await store.addComponentToCanvas(
args.canvas_id,
args.component_id,
args.props as Record<string, any>,
args.position
)
if (success) {
return `Componente "${args.component_id}" agregado al canvas "${args.canvas_id}"`
}
return 'Error al agregar componente al canvas'
}
)
// remove_component_from_canvas
registerTool(
'remove_component_from_canvas',
'Remueve un componente de un canvas',
{
type: 'object',
properties: {
canvas_id: { type: 'string', description: 'ID del canvas' },
component_id: { type: 'string', description: 'ID del componente a remover' }
},
required: ['canvas_id', 'component_id']
},
async (args: { canvas_id: string; component_id: string }) => {
const success = await store.removeComponentFromCanvas(args.canvas_id, args.component_id)
if (success) {
return `Componente "${args.component_id}" removido del canvas "${args.canvas_id}"`
}
return 'Error al remover componente del canvas'
}
)
// get_canvas_components
registerTool(
'get_canvas_components',
'Obtiene los componentes de un canvas con sus definiciones',
{
type: 'object',
properties: {
canvas_id: { type: 'string', description: 'ID del canvas' }
},
required: ['canvas_id']
},
async (args: { canvas_id: string }) => {
await store.fetchCanvasComponents(args.canvas_id)
return JSON.stringify(store.activeCanvasComponents, null, 2)
}
)
}
export function unregisterProjectCanvasTools() {
unregisterTools(PROJECT_CANVAS_TOOLS)
}

View File

@@ -1,290 +0,0 @@
import { registerTool, unregisterTools } from '../webmcp'
export const SOURCE_CODE_TOOLS = [
'get_repo_info',
'list_repo_files',
'read_repo_file',
'search_repo_code'
]
const API_BASE = 'http://localhost:4101'
// Store credentials in memory (not persisted)
let giteaCredentials: {
giteaUrl: string
username: string
password: string
owner: string
repo: string
branch: string
} | null = null
export function setGiteaCredentials(creds: typeof giteaCredentials) {
giteaCredentials = creds
}
export function registerSourceCodeTools() {
// get_repo_info
registerTool(
'get_repo_info',
'Obtiene informacion del repositorio conectado en Gitea',
{
type: 'object',
properties: {}
},
async () => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en la pagina de Source Code.'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/repo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(giteaCredentials)
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
const repo = data.repo
return `Repositorio: ${repo.owner.login}/${repo.name}\n` +
`Descripcion: ${repo.description || 'Sin descripcion'}\n` +
`Rama default: ${repo.default_branch}\n` +
`Stars: ${repo.stars_count}\n` +
`Forks: ${repo.forks_count}\n` +
`Ramas disponibles: ${data.branches.join(', ')}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// list_repo_files
registerTool(
'list_repo_files',
'Lista archivos y carpetas en una ruta del repositorio',
{
type: 'object',
properties: {
path: {
type: 'string',
description: 'Ruta dentro del repositorio (vacio para raiz)'
}
}
},
async (args: { path?: string }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en la pagina de Source Code.'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/tree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...giteaCredentials,
path: args.path || ''
})
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
const path = args.path || '/'
if (data.tree.length === 0) {
return `No hay archivos en ${path}`
}
const folders = data.tree.filter((f: any) => f.type === 'dir')
const files = data.tree.filter((f: any) => f.type === 'file')
let result = `Contenido de ${path}:\n\n`
if (folders.length > 0) {
result += `Carpetas (${folders.length}):\n`
result += folders.map((f: any) => ` [DIR] ${f.name}/`).join('\n')
result += '\n\n'
}
if (files.length > 0) {
result += `Archivos (${files.length}):\n`
result += files.map((f: any) => ` ${f.name}`).join('\n')
}
return result
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// read_repo_file
registerTool(
'read_repo_file',
'Lee el contenido de un archivo del repositorio',
{
type: 'object',
properties: {
path: {
type: 'string',
description: 'Ruta del archivo dentro del repositorio'
},
lines: {
type: 'number',
description: 'Numero maximo de lineas a retornar (default: 100)'
}
},
required: ['path']
},
async (args: { path: string; lines?: number }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en la pagina de Source Code.'
}
try {
const res = await fetch(`${API_BASE}/api/gitea/file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...giteaCredentials,
path: args.path
})
})
if (!res.ok) {
const err = await res.json()
return `Error: ${err.error}`
}
const data = await res.json()
let content = data.content
// Limit lines if specified
const maxLines = args.lines || 100
const lines = content.split('\n')
if (lines.length > maxLines) {
content = lines.slice(0, maxLines).join('\n')
content += `\n\n... (${lines.length - maxLines} lineas mas)`
}
return `Archivo: ${args.path}\nTamano: ${data.size} bytes\n\n${content}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// search_repo_code
registerTool(
'search_repo_code',
'Busca codigo en el repositorio (busqueda simple en archivos)',
{
type: 'object',
properties: {
query: {
type: 'string',
description: 'Texto a buscar en los archivos'
},
path: {
type: 'string',
description: 'Ruta donde buscar (default: raiz)'
},
extension: {
type: 'string',
description: 'Extension de archivos a buscar (ej: ts, vue, js)'
}
},
required: ['query']
},
async (args: { query: string; path?: string; extension?: string }) => {
if (!giteaCredentials) {
return 'No hay conexion a Gitea. Conectate primero en la pagina de Source Code.'
}
try {
// First get the file tree
const treeRes = await fetch(`${API_BASE}/api/gitea/tree`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...giteaCredentials,
path: args.path || ''
})
})
if (!treeRes.ok) {
return 'Error al obtener lista de archivos'
}
const treeData = await treeRes.json()
const files = treeData.tree.filter((f: any) => {
if (f.type !== 'file') return false
if (args.extension) {
return f.name.endsWith(`.${args.extension}`)
}
return true
})
const results: string[] = []
const maxFiles = 10
let filesSearched = 0
for (const file of files.slice(0, maxFiles)) {
try {
const fileRes = await fetch(`${API_BASE}/api/gitea/file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...giteaCredentials,
path: file.path
})
})
if (fileRes.ok) {
const fileData = await fileRes.json()
const lines = fileData.content.split('\n')
const matches: string[] = []
lines.forEach((line: string, idx: number) => {
if (line.toLowerCase().includes(args.query.toLowerCase())) {
matches.push(` L${idx + 1}: ${line.trim().substring(0, 80)}`)
}
})
if (matches.length > 0) {
results.push(`${file.path}:\n${matches.slice(0, 5).join('\n')}`)
}
}
filesSearched++
} catch (e) {
// Skip file errors
}
}
if (results.length === 0) {
return `No se encontro "${args.query}" en los primeros ${filesSearched} archivos`
}
return `Busqueda: "${args.query}"\n` +
`Archivos buscados: ${filesSearched}\n` +
`Coincidencias:\n\n${results.join('\n\n')}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
}
export function unregisterSourceCodeTools() {
unregisterTools(SOURCE_CODE_TOOLS)
giteaCredentials = null
}

View File

@@ -1,524 +0,0 @@
import { useThemeStore } from '../../stores/theme'
import { registerTool, unregisterTools } from '../webmcp'
export const THEME_TOOLS = [
'get_design_tokens',
'get_active_theme',
'set_theme_variable',
'save_theme',
'update_theme',
'list_themes',
'switch_theme',
'set_default_theme',
'delete_theme',
'reset_theme',
'export_theme'
]
export function registerThemeTools() {
// get_design_tokens
registerTool(
'get_design_tokens',
'Obtiene los design tokens y guía de estilos del tema activo. Usa esto para crear componentes con estilos consistentes.',
{
type: 'object',
properties: {
category: {
type: 'string',
enum: ['all', 'colors', 'text', 'accent', 'semantic', 'spacing', 'typography', 'effects'],
description: 'Categoría específica de tokens. Por defecto "all" retorna todos.'
}
}
},
async (args: { category?: string }) => {
try {
const themeStore = useThemeStore()
const theme = themeStore.activeTheme
if (!theme) {
return 'No hay tema activo. Usa las variables CSS por defecto.'
}
const category = args.category || 'all'
const variables = theme.variables
if (category !== 'all' && variables[category as keyof typeof variables]) {
const categoryVars = variables[category as keyof typeof variables]
const tokenList = Object.entries(categoryVars)
.map(([name, value]) => `--${name}: ${value}`)
.join('\n')
return `Design Tokens - ${category.toUpperCase()}:\n\n${tokenList}\n\nUsa estas variables CSS en tus estilos para mantener consistencia con el tema.`
}
// Return all tokens organized by category
const allTokens = Object.entries(variables)
.map(([cat, vars]) => {
const tokenList = Object.entries(vars as Record<string, string>)
.map(([name, value]) => ` --${name}: ${value}`)
.join('\n')
return `[${cat.toUpperCase()}]\n${tokenList}`
})
.join('\n\n')
return `Design Tokens del tema "${theme.name}":\n\n${allTokens}\n\n` +
`GUÍA DE USO:\n` +
`- Usa var(--nombre-variable) en CSS\n` +
`- Los componentes dinámicos tienen acceso a $theme.getVariable('nombre')\n` +
`- Puedes modificar temporalmente con $theme.setVariable('nombre', 'valor')\n` +
`- Colores semánticos: success, warning, error, info (con -bg para fondos)\n` +
`- Radius: radius-sm (4px), radius-md (8px), radius-lg (12px), radius-full (9999px)`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// get_active_theme
registerTool(
'get_active_theme',
'Obtiene información del tema actualmente activo',
{
type: 'object',
properties: {}
},
() => {
try {
const themeStore = useThemeStore()
const theme = themeStore.activeTheme
if (!theme) {
return 'No hay tema activo'
}
return `Tema activo: "${theme.name}"\n` +
`ID: ${theme.id}\n` +
`Sistema: ${theme.is_system ? 'Sí' : 'No'}\n` +
`Default: ${theme.is_default ? 'Sí' : 'No'}\n` +
`Descripción: ${theme.description || 'Sin descripción'}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// set_theme_variable
registerTool(
'set_theme_variable',
'Modifica una variable CSS del tema en tiempo real (cambio temporal hasta que uses save_theme)',
{
type: 'object',
properties: {
name: {
type: 'string',
description: 'Nombre de la variable sin el prefijo -- (ej: "accent", "bg-primary")'
},
value: {
type: 'string',
description: 'Nuevo valor para la variable (ej: "#ff0000", "12px")'
}
},
required: ['name', 'value']
},
(args: { name: string; value: string }) => {
try {
const themeStore = useThemeStore()
const root = document.documentElement
const varName = args.name.startsWith('--') ? args.name : `--${args.name}`
const keyName = args.name.startsWith('--') ? args.name.slice(2) : args.name
// Get current value for feedback
const currentValue = getComputedStyle(root).getPropertyValue(varName).trim()
// Set new value in DOM
root.style.setProperty(varName, args.value)
// Update the store's previewTheme to track changes
if (themeStore.activeTheme) {
// Find which category this variable belongs to
const variables = themeStore.activeTheme.variables
for (const category of Object.keys(variables) as (keyof typeof variables)[]) {
if (keyName in variables[category]) {
themeStore.updateVariable(category, keyName, args.value)
break
}
}
}
return `Variable ${varName} cambiada:\n` +
` Anterior: ${currentValue || '(no definida)'}\n` +
` Nuevo: ${args.value}\n\n` +
`Usa save_theme para guardar los cambios permanentemente.`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// save_theme
registerTool(
'save_theme',
'Guarda el tema actual con los cambios realizados permanentemente en la base de datos',
{
type: 'object',
properties: {
name: {
type: 'string',
description: 'Nombre para el nuevo tema'
},
description: {
type: 'string',
description: 'Descripción opcional del tema'
},
setAsDefault: {
type: 'boolean',
description: 'Si es true, establece este tema como el activo por defecto'
}
},
required: ['name']
},
async (args: { name: string; description?: string; setAsDefault?: boolean }) => {
try {
const themeStore = useThemeStore()
// Get variables to save (preview has the modified values, or use active)
const variablesToSave = themeStore.previewTheme || themeStore.activeTheme?.variables
if (!variablesToSave) {
return 'Error: No hay tema con variables para guardar'
}
// Save the theme
const result = await themeStore.saveTheme({
name: args.name,
description: args.description || `Tema creado el ${new Date().toLocaleString()}`,
variables: variablesToSave,
metadata: {
author: 'Claude',
version: '1.0',
base: themeStore.activeTheme?.id || null
}
})
// Set as default if requested
if (args.setAsDefault && result.id) {
await themeStore.setDefaultTheme(result.id)
return `Tema "${args.name}" guardado y establecido como default.\nID: ${result.id}`
}
return `Tema "${args.name}" guardado permanentemente en la base de datos.\nID: ${result.id}`
} catch (e: any) {
return `Error al guardar tema: ${e.message}`
}
}
)
// update_theme
registerTool(
'update_theme',
'Actualiza un tema existente (nombre, descripción o variables)',
{
type: 'object',
properties: {
theme: {
type: 'string',
description: 'Nombre o ID del tema a actualizar'
},
name: {
type: 'string',
description: 'Nuevo nombre para el tema (opcional)'
},
description: {
type: 'string',
description: 'Nueva descripción para el tema (opcional)'
},
saveCurrentVariables: {
type: 'boolean',
description: 'Si es true, guarda las variables actuales (con los cambios de set_theme_variable) en este tema'
}
},
required: ['theme']
},
async (args: { theme: string; name?: string; description?: string; saveCurrentVariables?: boolean }) => {
try {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
// Find theme by ID or name
const theme = themeStore.themes.find(t =>
t.id === args.theme || t.name.toLowerCase() === args.theme.toLowerCase()
)
if (!theme) {
const available = themeStore.themes.map(t => t.name).join(', ')
return `Tema "${args.theme}" no encontrado.\nDisponibles: ${available}`
}
if (theme.is_system) {
return `No se puede modificar "${theme.name}" porque es un tema del sistema. Usa save_theme para crear una copia.`
}
// Build update data
const updateData: { name?: string; description?: string; variables?: any } = {}
if (args.name) {
updateData.name = args.name
}
if (args.description !== undefined) {
updateData.description = args.description
}
if (args.saveCurrentVariables) {
const variablesToSave = themeStore.previewTheme || themeStore.activeTheme?.variables
if (variablesToSave) {
updateData.variables = variablesToSave
}
}
if (Object.keys(updateData).length === 0) {
return 'No se especificaron cambios. Usa name, description o saveCurrentVariables.'
}
await themeStore.updateTheme(theme.id, updateData)
const changes = []
if (args.name) changes.push(`nombre: "${args.name}"`)
if (args.description !== undefined) changes.push('descripción actualizada')
if (args.saveCurrentVariables) changes.push('variables guardadas')
return `Tema "${theme.name}" actualizado:\n ${changes.join('\n ')}`
} catch (e: any) {
return `Error al actualizar tema: ${e.message}`
}
}
)
// list_themes
registerTool(
'list_themes',
'Lista todos los temas disponibles (del sistema y personalizados)',
{
type: 'object',
properties: {}
},
async () => {
try {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
const themes = themeStore.themes
if (themes.length === 0) {
return 'No hay temas disponibles'
}
const systemThemes = themes.filter(t => t.is_system)
const userThemes = themes.filter(t => !t.is_system)
let result = `Temas disponibles (${themes.length}):\n\n`
if (systemThemes.length > 0) {
result += `[SISTEMA]\n`
result += systemThemes.map(t =>
` - ${t.name} (${t.id})${t.is_default ? ' [DEFAULT]' : ''}`
).join('\n')
result += '\n\n'
}
if (userThemes.length > 0) {
result += `[PERSONALIZADOS]\n`
result += userThemes.map(t =>
` - ${t.name} (${t.id})${t.is_default ? ' [DEFAULT]' : ''}`
).join('\n')
}
return result
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// switch_theme
registerTool(
'switch_theme',
'Cambia al tema especificado por nombre o ID',
{
type: 'object',
properties: {
theme: {
type: 'string',
description: 'Nombre o ID del tema a activar'
}
},
required: ['theme']
},
async (args: { theme: string }) => {
try {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
// Find theme by ID or name
const theme = themeStore.themes.find(t =>
t.id === args.theme || t.name.toLowerCase() === args.theme.toLowerCase()
)
if (!theme) {
const available = themeStore.themes.map(t => t.name).join(', ')
return `Tema "${args.theme}" no encontrado.\nDisponibles: ${available}`
}
themeStore.selectTheme(theme)
return `Tema cambiado a "${theme.name}"\nID: ${theme.id}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// set_default_theme
registerTool(
'set_default_theme',
'Establece un tema como el default (se cargará automáticamente al iniciar)',
{
type: 'object',
properties: {
theme: {
type: 'string',
description: 'Nombre o ID del tema a establecer como default'
}
},
required: ['theme']
},
async (args: { theme: string }) => {
try {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
const theme = themeStore.themes.find(t =>
t.id === args.theme || t.name.toLowerCase() === args.theme.toLowerCase()
)
if (!theme) {
const available = themeStore.themes.map(t => t.name).join(', ')
return `Tema "${args.theme}" no encontrado.\nDisponibles: ${available}`
}
await themeStore.setDefaultTheme(theme.id)
return `Tema "${theme.name}" establecido como default.\nSe cargará automáticamente al iniciar la aplicación.`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// delete_theme
registerTool(
'delete_theme',
'Elimina un tema personalizado (no se pueden eliminar temas del sistema)',
{
type: 'object',
properties: {
theme: {
type: 'string',
description: 'Nombre o ID del tema a eliminar'
}
},
required: ['theme']
},
async (args: { theme: string }) => {
try {
const themeStore = useThemeStore()
await themeStore.fetchThemes()
const theme = themeStore.themes.find(t =>
t.id === args.theme || t.name.toLowerCase() === args.theme.toLowerCase()
)
if (!theme) {
return `Tema "${args.theme}" no encontrado`
}
if (theme.is_system) {
return `No se puede eliminar "${theme.name}" porque es un tema del sistema`
}
const success = await themeStore.deleteTheme(theme.id)
if (success) {
return `Tema "${theme.name}" eliminado correctamente`
} else {
return `Error al eliminar el tema "${theme.name}"`
}
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// reset_theme
registerTool(
'reset_theme',
'Descarta todos los cambios no guardados y restaura el tema activo original',
{
type: 'object',
properties: {}
},
() => {
try {
const themeStore = useThemeStore()
if (!themeStore.previewTheme) {
return 'No hay cambios pendientes para descartar'
}
const themeName = themeStore.activeTheme?.name || 'desconocido'
themeStore.resetPreview()
return `Cambios descartados. Tema "${themeName}" restaurado a su estado original.`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// export_theme
registerTool(
'export_theme',
'Exporta un tema como JSON para respaldo o compartir',
{
type: 'object',
properties: {
theme: {
type: 'string',
description: 'Nombre o ID del tema a exportar. Si no se especifica, exporta el tema activo.'
}
}
},
async (args: { theme?: string }) => {
try {
const themeStore = useThemeStore()
let theme = themeStore.activeTheme
if (args.theme) {
await themeStore.fetchThemes()
theme = themeStore.themes.find(t =>
t.id === args.theme || t.name.toLowerCase() === args.theme!.toLowerCase()
) || null
}
if (!theme) {
return args.theme
? `Tema "${args.theme}" no encontrado`
: 'No hay tema activo para exportar'
}
const exported = themeStore.exportTheme(theme)
return `Tema "${theme.name}" exportado:\n\n${exported}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
}
export function unregisterThemeTools() {
unregisterTools(THEME_TOOLS)
}

View File

@@ -0,0 +1,95 @@
export type ToolCategory = 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project' | 'terminal'
export interface ToolMeta {
name: string
description: string
category: ToolCategory
}
// All tool metadata (name, description, category)
export const ALL_TOOL_METAS: ToolMeta[] = [
// Global tools
{ name: 'get_current_page', description: 'Obtiene la pagina actualmente activa', category: 'global' },
{ name: 'navigate_to', description: 'Navega a una pagina especifica', category: 'global' },
{ name: 'list_available_tools', description: 'Lista todas las herramientas MCP disponibles', category: 'global' },
{ name: 'activate_tool', description: 'Activa una herramienta MCP', category: 'global' },
{ name: 'deactivate_tool', description: 'Desactiva una herramienta MCP', category: 'global' },
{ name: 'pin_tool', description: 'Pinnea una herramienta', category: 'global' },
// Canvas tools
{ name: 'render_html', description: 'Renderiza HTML en el canvas', category: 'canvas' },
{ name: 'render_vue_component', description: 'Renderiza un componente Vue 3 completo', category: 'canvas' },
// Component tools
{ name: 'save_vue_component', description: 'Guarda un componente Vue en la base de datos', category: 'component' },
{ name: 'load_vue_component', description: 'Carga y renderiza un componente guardado', category: 'component' },
{ name: 'list_vue_components', description: 'Lista componentes guardados', category: 'component' },
{ name: 'delete_vue_component', description: 'Elimina un componente', category: 'component' },
// Theme tools
{ name: 'get_design_tokens', description: 'Obtiene los design tokens del tema activo', category: 'theme' },
{ name: 'get_active_theme', description: 'Obtiene info del tema activo', category: 'theme' },
{ name: 'set_theme_variable', description: 'Modifica una variable CSS del tema', category: 'theme' },
{ name: 'save_theme', description: 'Guarda el tema actual', category: 'theme' },
{ name: 'list_themes', description: 'Lista todos los temas disponibles', category: 'theme' },
{ name: 'switch_theme', description: 'Cambia al tema especificado', category: 'theme' },
{ name: 'reset_theme', description: 'Descarta cambios no guardados', category: 'theme' },
// Database tools
{ name: 'list_tables', description: 'Lista todas las tablas de la base de datos', category: 'database' },
{ name: 'get_table_schema', description: 'Obtiene el esquema de una tabla', category: 'database' },
{ name: 'get_table_data', description: 'Obtiene los datos de una tabla', category: 'database' },
{ name: 'get_database_stats', description: 'Obtiene estadisticas de la base de datos', category: 'database' },
{ name: 'execute_query', description: 'Ejecuta una consulta SQL SELECT', category: 'database' },
// Source code tools
{ name: 'get_repo_info', description: 'Obtiene info del repositorio Gitea', category: 'source' },
{ name: 'list_repo_files', description: 'Lista archivos del repositorio', category: 'source' },
{ name: 'read_repo_file', description: 'Lee contenido de un archivo', category: 'source' },
{ name: 'search_repo_code', description: 'Busca codigo en el repositorio', category: 'source' },
// Project canvas tools
{ name: 'list_canvases', description: 'Lista todos los canvas disponibles', category: 'project' },
{ name: 'create_canvas', description: 'Crea un nuevo project canvas', category: 'project' },
{ name: 'get_canvas', description: 'Obtiene detalles de un canvas', category: 'project' },
{ name: 'update_canvas', description: 'Actualiza un canvas existente', category: 'project' },
{ name: 'delete_canvas', description: 'Elimina un canvas', category: 'project' },
{ name: 'clone_canvas', description: 'Clona un canvas existente', category: 'project' },
{ name: 'add_component_to_canvas', description: 'Agrega un componente a un canvas', category: 'project' },
{ name: 'remove_component_from_canvas', description: 'Remueve un componente de un canvas', category: 'project' },
{ name: 'get_canvas_components', description: 'Obtiene los componentes de un canvas', category: 'project' },
// Terminal UI tools
{ name: 'terminal_open', description: 'Abre la ventana flotante del terminal', category: 'terminal' },
{ name: 'terminal_close', description: 'Cierra la ventana flotante del terminal', category: 'terminal' },
{ name: 'terminal_toggle', description: 'Alterna abrir/cerrar el terminal', category: 'terminal' },
{ name: 'terminal_move', description: 'Mueve la ventana del terminal a una posicion', category: 'terminal' },
{ name: 'terminal_resize', description: 'Cambia el tamano de la ventana del terminal', category: 'terminal' }
]
// Get all tool names
export function getAllToolNames(): string[] {
return ALL_TOOL_METAS.map(t => t.name)
}
// Get tool metadata by name
export function getToolMeta(name: string): ToolMeta | undefined {
return ALL_TOOL_METAS.find(t => t.name === name)
}
// Get tools by category
export function getToolsByCategory(category: ToolCategory): ToolMeta[] {
return ALL_TOOL_METAS.filter(t => t.category === category)
}
// Category display info
export const CATEGORY_INFO: Record<ToolCategory, { label: string; color: string; icon: string }> = {
global: { label: 'Global', color: '#6366f1', icon: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2z' },
canvas: { label: 'Canvas', color: '#10b981', icon: 'M3 3h18v18H3V3z' },
component: { label: 'Component', color: '#f59e0b', icon: 'M21 16V8l-7-4-7 4v8l7 4 7-4z' },
theme: { label: 'Theme', color: '#ec4899', icon: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10' },
database: { label: 'Database', color: '#3b82f6', icon: 'M12 2C7 2 3 3.5 3 5v14c0 1.5 4 3 9 3s9-1.5 9-3V5c0-1.5-4-3-9-3z' },
source: { label: 'Source', color: '#8b5cf6', icon: 'M16 18l6-6-6-6M8 6l-6 6 6 6' },
project: { label: 'Project', color: '#06b6d4', icon: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z' },
terminal: { label: 'Terminal', color: '#22c55e', icon: 'M4 17l6-6-6-6M12 19h8' }
}

View File

@@ -2,6 +2,7 @@ import { useCanvasStore } from '../stores/canvas'
let webmcpInstance: any = null let webmcpInstance: any = null
const registeredTools = new Set<string>() const registeredTools = new Set<string>()
const eventUnsubscribers: Array<() => void> = []
const API_BASE = 'http://localhost:4101' const API_BASE = 'http://localhost:4101'
let tokenPollingInterval: number | null = null let tokenPollingInterval: number | null = null
@@ -13,31 +14,130 @@ export async function initWebMCP() {
const WebMCP = WebMCPModule.default || WebMCPModule const WebMCP = WebMCPModule.default || WebMCPModule
webmcpInstance = new WebMCP({ webmcpInstance = new WebMCP({
color: '#6366f1', headless: true,
position: 'bottom-right',
inactivityTimeout: 60 * 60 * 1000 // 1 hora inactivityTimeout: 60 * 60 * 1000 // 1 hora
}) })
const canvasStore = useCanvasStore() setupEventHandlers()
webmcpInstance.on?.('connected', () => {
canvasStore.setConnected(true)
})
webmcpInstance.on?.('disconnected', () => {
canvasStore.setConnected(false)
})
// Check initial connection state
if (webmcpInstance.isConnected) { if (webmcpInstance.isConnected) {
const canvasStore = useCanvasStore()
canvasStore.setConnected(true) canvasStore.setConnected(true)
updateConnectionInfo()
} }
// Exponer globalmente para debug // Expose globally for debug
;(window as any).webmcp = webmcpInstance ;(window as any).webmcp = webmcpInstance
return webmcpInstance return webmcpInstance
} }
function setupEventHandlers() {
// Skip if instance doesn't support events
if (typeof webmcpInstance.on !== 'function') {
console.warn('[WebMCP] Event emitter not available')
return
}
const canvasStore = useCanvasStore()
// Connection events
eventUnsubscribers.push(
webmcpInstance.on('connected', () => {
console.log('[WebMCP] Connected')
canvasStore.setConnected(true)
canvasStore.setReconnecting(false)
canvasStore.setConnectionError(null)
updateConnectionInfo()
})
)
eventUnsubscribers.push(
webmcpInstance.on('disconnected', () => {
console.log('[WebMCP] Disconnected')
canvasStore.setConnected(false)
canvasStore.setReconnecting(false)
canvasStore.setConnectionInfo(null)
})
)
eventUnsubscribers.push(
webmcpInstance.on('reconnecting', () => {
console.log('[WebMCP] Reconnecting...')
canvasStore.setReconnecting(true)
})
)
// Status changes
eventUnsubscribers.push(
webmcpInstance.on('statusChange', (data: { status: string }) => {
canvasStore.setConnectionStatus(data.status)
})
)
// Error handling
eventUnsubscribers.push(
webmcpInstance.on('error', (data: { message: string }) => {
console.error('[WebMCP] Error:', data.message)
canvasStore.setConnectionError(data.message)
canvasStore.showNotification(data.message, 'error')
})
)
// Tool events
eventUnsubscribers.push(
webmcpInstance.on('toolRegistered', (data: { name: string }) => {
console.log('[WebMCP] Tool registered by server:', data.name)
updateConnectionInfo()
})
)
eventUnsubscribers.push(
webmcpInstance.on('toolCreated', (data: { name: string }) => {
console.log('[WebMCP] Tool created:', data.name)
registeredTools.add(data.name)
updateConnectionInfo()
})
)
eventUnsubscribers.push(
webmcpInstance.on('toolRemoved', (data: { name: string }) => {
if (data.name === '*') {
console.log('[WebMCP] All tools removed')
registeredTools.clear()
} else {
console.log('[WebMCP] Tool removed:', data.name)
registeredTools.delete(data.name)
}
updateConnectionInfo()
})
)
}
function updateConnectionInfo() {
if (!webmcpInstance) return
const canvasStore = useCanvasStore()
const info = webmcpInstance.getConnectionInfo?.()
if (info) {
canvasStore.setConnectionInfo({
isConnected: info.isConnected,
channel: info.channel,
server: info.server,
status: info.status,
tools: info.tools || [],
prompts: info.prompts || [],
resources: info.resources || []
})
}
}
export function getConnectionInfo() {
return webmcpInstance?.getConnectionInfo?.() || null
}
export function getWebMCP() { export function getWebMCP() {
return webmcpInstance return webmcpInstance
} }
@@ -91,6 +191,26 @@ export function clearAllTools() {
registeredTools.clear() registeredTools.clear()
} }
export function destroyWebMCP() {
// Unsubscribe all event handlers
for (const unsub of eventUnsubscribers) {
unsub()
}
eventUnsubscribers.length = 0
// Clear tools
clearAllTools()
// Disconnect if connected
if (webmcpInstance?.disconnect) {
webmcpInstance.disconnect()
}
webmcpInstance = null
;(window as any).webmcp = null
console.log('[WebMCP] Instance destroyed')
}
export function getRegisteredTools(): string[] { export function getRegisteredTools(): string[] {
return [...registeredTools] return [...registeredTools]
} }
@@ -151,27 +271,27 @@ export function parseToken(token: string): { server: string; token: string } | n
} }
export async function connectWithToken(token: string): Promise<boolean> { export async function connectWithToken(token: string): Promise<boolean> {
const parsed = parseToken(token) if (!webmcpInstance) {
if (!parsed) return false console.error('[WebMCP] Instance not initialized')
return false
}
console.log('[WebMCP] Connecting with token to:', parsed.server) if (typeof webmcpInstance.connect !== 'function') {
console.error('[WebMCP] connect method not available')
return false
}
// Store token for webmcp to use console.log('[WebMCP] Connecting with token...')
localStorage.setItem('webmcp_token', token)
// Clear the pending token from server // Clear the pending token from server
await clearToken() await clearToken()
// If webmcp is already initialized, try to reconnect // Connect passing the token directly
if (webmcpInstance && typeof webmcpInstance.connect === 'function') {
try { try {
await webmcpInstance.connect() await webmcpInstance.connect(token)
return true return true
} catch (e) { } catch (e) {
console.error('[WebMCP] Failed to connect:', e) console.error('[WebMCP] Failed to connect:', e)
return false return false
} }
} }
return true
}

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref, computed } from 'vue'
interface HistoryEntry { interface HistoryEntry {
tool: string tool: string
@@ -14,16 +14,60 @@ interface Notification {
duration: number duration: number
} }
interface ConnectionInfo {
isConnected: boolean
channel: string | null
server: string | null
status: string
tools: string[]
prompts: string[]
resources: string[]
}
export const useCanvasStore = defineStore('canvas', () => { export const useCanvasStore = defineStore('canvas', () => {
// Connection state
const isConnected = ref(false) const isConnected = ref(false)
const isReconnecting = ref(false)
const connectionStatus = ref<string>('disconnected')
const connectionError = ref<string | null>(null)
const connectionInfo = ref<ConnectionInfo | null>(null)
const history = ref<HistoryEntry[]>([]) const history = ref<HistoryEntry[]>([])
const notifications = ref<Notification[]>([]) const notifications = ref<Notification[]>([])
const showHistoryPanel = ref(false) const showHistoryPanel = ref(false)
let notificationId = 0 let notificationId = 0
// Computed
const statusColor = computed(() => {
if (isReconnecting.value) return 'warning'
if (isConnected.value) return 'success'
if (connectionError.value) return 'error'
return 'muted'
})
function setConnected(connected: boolean) { function setConnected(connected: boolean) {
isConnected.value = connected isConnected.value = connected
if (connected) {
isReconnecting.value = false
connectionError.value = null
}
}
function setReconnecting(reconnecting: boolean) {
isReconnecting.value = reconnecting
}
function setConnectionStatus(status: string) {
connectionStatus.value = status
}
function setConnectionError(error: string | null) {
connectionError.value = error
}
function setConnectionInfo(info: ConnectionInfo | null) {
connectionInfo.value = info
} }
function addToHistory(entry: HistoryEntry) { function addToHistory(entry: HistoryEntry) {
@@ -60,11 +104,23 @@ export const useCanvasStore = defineStore('canvas', () => {
} }
return { return {
// Connection state
isConnected, isConnected,
isReconnecting,
connectionStatus,
connectionError,
connectionInfo,
statusColor,
// History & UI
history, history,
notifications, notifications,
showHistoryPanel, showHistoryPanel,
// Actions
setConnected, setConnected,
setReconnecting,
setConnectionStatus,
setConnectionError,
setConnectionInfo,
addToHistory, addToHistory,
clearHistory, clearHistory,
showNotification, showNotification,

View File

@@ -0,0 +1,170 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface ToolDefinition {
name: string
description: string
category: 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project'
schema: object
handler: Function
}
export const useToolsStore = defineStore('tools', () => {
// All available tool definitions
const toolDefinitions = ref<Map<string, ToolDefinition>>(new Map())
// Pinned tools persist across page changes (use array for reactivity)
const pinnedToolsArray = ref<string[]>([])
// Currently active tools (use array for reactivity)
const activeToolsArray = ref<string[]>([])
// Computed
const allTools = computed(() => Array.from(toolDefinitions.value.values()))
const activeToolsDefs = computed(() =>
allTools.value.filter(t => activeToolsArray.value.includes(t.name))
)
const inactiveToolsDefs = computed(() =>
allTools.value.filter(t => !activeToolsArray.value.includes(t.name))
)
const pinnedToolsDefs = computed(() =>
allTools.value.filter(t => pinnedToolsArray.value.includes(t.name))
)
const toolsByCategory = computed(() => {
const categories: Record<string, { active: ToolDefinition[], inactive: ToolDefinition[] }> = {}
for (const tool of allTools.value) {
if (!categories[tool.category]) {
categories[tool.category] = { active: [], inactive: [] }
}
if (activeToolsArray.value.includes(tool.name)) {
categories[tool.category].active.push(tool)
} else {
categories[tool.category].inactive.push(tool)
}
}
return categories
})
// Actions
function registerToolDefinition(tool: ToolDefinition) {
toolDefinitions.value.set(tool.name, tool)
}
function registerToolDefinitions(tools: ToolDefinition[]) {
for (const tool of tools) {
toolDefinitions.value.set(tool.name, tool)
}
}
function setToolActive(name: string, active: boolean) {
const index = activeToolsArray.value.indexOf(name)
if (active && index === -1) {
activeToolsArray.value.push(name)
} else if (!active && index !== -1) {
activeToolsArray.value.splice(index, 1)
}
}
function pinTool(name: string) {
if (!pinnedToolsArray.value.includes(name)) {
pinnedToolsArray.value.push(name)
savePinnedTools()
}
}
function unpinTool(name: string) {
const index = pinnedToolsArray.value.indexOf(name)
if (index !== -1) {
pinnedToolsArray.value.splice(index, 1)
savePinnedTools()
}
}
function togglePin(name: string) {
if (pinnedToolsArray.value.includes(name)) {
unpinTool(name)
} else {
pinTool(name)
}
}
function isToolPinned(name: string): boolean {
return pinnedToolsArray.value.includes(name)
}
function isToolActive(name: string): boolean {
return activeToolsArray.value.includes(name)
}
function getPinnedToolNames(): string[] {
return [...pinnedToolsArray.value]
}
function getToolDefinition(name: string): ToolDefinition | undefined {
return toolDefinitions.value.get(name)
}
// Persistence
function savePinnedTools() {
localStorage.setItem('pinnedTools', JSON.stringify(pinnedToolsArray.value))
}
function loadPinnedTools() {
try {
const saved = localStorage.getItem('pinnedTools')
if (saved) {
const parsed = JSON.parse(saved)
pinnedToolsArray.value = parsed
}
} catch (e) {
console.error('[ToolsStore] Failed to load pinned tools:', e)
}
}
function clearActiveTools() {
activeToolsArray.value = []
}
function setActiveTools(names: string[]) {
activeToolsArray.value = [...names]
}
// Initialize
loadPinnedTools()
return {
// State (reactive arrays)
activeTools: activeToolsArray,
pinnedTools: pinnedToolsArray,
toolDefinitions,
// Computed
allTools,
activeToolsDefs,
inactiveToolsDefs,
pinnedToolsDefs,
toolsByCategory,
// Actions
registerToolDefinition,
registerToolDefinitions,
setToolActive,
pinTool,
unpinTool,
togglePin,
isToolPinned,
isToolActive,
getPinnedToolNames,
getToolDefinition,
clearActiveTools,
setActiveTools,
loadPinnedTools
}
})

View File

@@ -18,15 +18,20 @@
html, body { html, body {
height: 100%; height: 100%;
min-height: 100vh;
min-height: 100dvh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary); background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
overflow: hidden;
} }
#app { #app {
height: 100%; height: 100%;
min-height: 100vh;
min-height: 100dvh;
} }
/* Scrollbar styling */ /* Scrollbar styling */

View File

@@ -0,0 +1,23 @@
export interface TableInfo {
name: string
count: number
}
export interface TableSchema {
name: string
type: string
notnull: boolean
pk: boolean
}
export interface DbStats {
size: string
tables: number
totalRecords: number
}
export interface PaginationState {
currentPage: number
pageSize: number
totalRecords: number
}

View File

@@ -3,6 +3,10 @@
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"], "types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */ /* Linting */
"strict": true, "strict": true,

View File

@@ -1,10 +1,15 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { fileURLToPath } from 'url'
import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, 'src'),
// Habilitar compilación de templates en runtime para componentes dinámicos // Habilitar compilación de templates en runtime para componentes dinámicos
'vue': 'vue/dist/vue.esm-bundler.js' 'vue': 'vue/dist/vue.esm-bundler.js'
} }
@@ -13,15 +18,36 @@ export default defineConfig({
vue(), vue(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'icons/*.png'], includeAssets: ['favicon.svg', 'icons/*.svg', 'icons/*.png'],
devOptions: {
enabled: true,
type: 'module',
suppressWarnings: true
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
navigateFallbackDenylist: [/^\/api\//],
// Don't cache API calls - let them go directly to network
runtimeCaching: []
},
manifest: { manifest: {
name: 'Agent UI - Dynamic Canvas', name: 'Agent UI',
short_name: 'AgentUI', short_name: 'AgentUI',
description: 'Dynamic canvas for Claude Code interaction', description: 'Dynamic canvas for Claude Code interaction via WebMCP',
theme_color: '#1a1a2e', theme_color: '#16161d',
background_color: '#1a1a2e', background_color: '#0f0f14',
display: 'standalone', display: 'standalone',
orientation: 'any',
start_url: '/',
scope: '/',
categories: ['developer', 'utilities'],
icons: [ icons: [
{
src: 'icons/icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any'
},
{ {
src: 'icons/icon-192.png', src: 'icons/icon-192.png',
sizes: '192x192', sizes: '192x192',
@@ -31,6 +57,12 @@ export default defineConfig({
src: 'icons/icon-512.png', src: 'icons/icon-512.png',
sizes: '512x512', sizes: '512x512',
type: 'image/png' type: 'image/png'
},
{
src: 'icons/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
} }
] ]
} }
@@ -39,6 +71,8 @@ export default defineConfig({
server: { server: {
port: 4100, port: 4100,
host: true, host: true,
allowedHosts: ['z590.interno.com', 'localhost'],
cors: true,
proxy: { proxy: {
'/api': 'http://localhost:4101' '/api': 'http://localhost:4101'
}, },

15
server/bun.lock Normal file
View File

@@ -0,0 +1,15 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "agent-ui-server",
"dependencies": {
"@skitee3000/bun-pty": "^0.3.3",
},
},
},
"packages": {
"@skitee3000/bun-pty": ["@skitee3000/bun-pty@0.3.3", "", {}, "sha512-y+kA3435zkFjh11KdQMy0ho/UkKN/iae0t9tTE7iZ762Oi2h31dGOvbRIJWeJZ2qYYTJvAd6NEIzJlZUhI6ukw=="],
}
}

13
server/config.ts Normal file
View 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
View 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
View 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
View 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')
}
}

File diff suppressed because it is too large Load Diff

31
server/package-lock.json generated Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "agent-ui-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agent-ui-server",
"version": "1.0.0",
"dependencies": {
"node-pty": "^1.1.0"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
}
}
}

250
server/routes/canvas.ts Normal file
View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

203
server/services/terminal.ts Normal file
View File

@@ -0,0 +1,203 @@
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)
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
}
// CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders })
}
// 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
}, { headers: corsHeaders })
}
// 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
View 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 })
}