Compare commits
9 Commits
9681ce4198
...
607527d98d
| Author | SHA1 | Date | |
|---|---|---|---|
| 607527d98d | |||
| 3a734f2426 | |||
| f3f0df9cf3 | |||
| 424afa060c | |||
| 3c57f95b90 | |||
| 4450d1e034 | |||
| da6111bd1f | |||
| 421b184829 | |||
| 645f51a74e |
@@ -36,7 +36,16 @@
|
||||
"Bash(bun add:*)",
|
||||
"mcp__agent-ui__localhost_4100-confetti",
|
||||
"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,
|
||||
|
||||
1
frontend/dev-dist/registerSW.js
Normal file
1
frontend/dev-dist/registerSW.js
Normal file
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
0
frontend/dev-dist/suppress-warnings.js
Normal file
0
frontend/dev-dist/suppress-warnings.js
Normal file
94
frontend/dev-dist/sw.js
Normal file
94
frontend/dev-dist/sw.js
Normal 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} didn’t 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
|
||||
1
frontend/dev-dist/sw.js.map
Normal file
1
frontend/dev-dist/sw.js.map
Normal file
File diff suppressed because one or more lines are too long
3396
frontend/dev-dist/workbox-5a5d9309.js
Normal file
3396
frontend/dev-dist/workbox-5a5d9309.js
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/dev-dist/workbox-5a5d9309.js.map
Normal file
1
frontend/dev-dist/workbox-5a5d9309.js.map
Normal file
File diff suppressed because one or more lines are too long
4540
frontend/dev-dist/workbox-c5fd805d.js
Normal file
4540
frontend/dev-dist/workbox-c5fd805d.js
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/dev-dist/workbox-c5fd805d.js.map
Normal file
1
frontend/dev-dist/workbox-c5fd805d.js.map
Normal file
File diff suppressed because one or more lines are too long
@@ -2,10 +2,21 @@
|
||||
<html lang="es">
|
||||
<head>
|
||||
<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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1a1a2e" />
|
||||
<meta name="description" content="Dynamic canvas for Claude Code interaction" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
|
||||
<title>Agent UI</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
739
frontend/package-lock.json
generated
739
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
||||
"predev": "npm install @nucleoriofrio/webmcp@git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git --silent",
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"generate-icons": "node scripts/generate-icons.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
|
||||
@@ -23,6 +24,7 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5"
|
||||
|
||||
BIN
frontend/public/favicon.png
Normal file
BIN
frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/public/icons/icon-maskable-512.png
Normal file
BIN
frontend/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
18
frontend/public/icons/icon.svg
Normal file
18
frontend/public/icons/icon.svg
Normal 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 |
43
frontend/scripts/generate-icons.js
Normal file
43
frontend/scripts/generate-icons.js
Normal 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)
|
||||
@@ -4,17 +4,22 @@ import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import StatusBar from './components/StatusBar.vue'
|
||||
import Toolbar from './components/Toolbar.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 PwaInstallBanner from './components/PwaInstallBanner.vue'
|
||||
import { initWebMCP, getWebMCP, startTokenPolling, stopTokenPolling, connectWithToken } from './services/webmcp'
|
||||
import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry'
|
||||
import { setTerminalControls } from './services/tools/handlers/terminalHandlers'
|
||||
import { useCanvasStore } from './stores/canvas'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const showTerminal = ref(false)
|
||||
const terminalRef = ref<InstanceType<typeof FloatingTerminal> | null>(null)
|
||||
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 () => {
|
||||
// Initialize WebMCP connection
|
||||
@@ -27,6 +32,43 @@ onMounted(async () => {
|
||||
const currentPage = (route.name as string) || 'canvas'
|
||||
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
|
||||
const webmcp = getWebMCP()
|
||||
if (!webmcp?.isConnected) {
|
||||
@@ -57,7 +99,10 @@ watch(() => route.name, (newPage) => {
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<h1 class="logo">Agent UI</h1>
|
||||
<ConnectionDropdown />
|
||||
<ComponentsDropdown />
|
||||
<ToolsDropdown />
|
||||
<PwaInstallBanner />
|
||||
</div>
|
||||
<StatusBar />
|
||||
</header>
|
||||
@@ -88,7 +133,7 @@ watch(() => route.name, (newPage) => {
|
||||
</button>
|
||||
|
||||
<!-- Floating Terminal -->
|
||||
<FloatingTerminal v-model="showTerminal" />
|
||||
<FloatingTerminal ref="terminalRef" v-model="showTerminal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -97,6 +142,7 @@ watch(() => route.name, (newPage) => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@@ -105,8 +151,12 @@ watch(() => route.name, (newPage) => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
|
||||
363
frontend/src/components/ConnectionDropdown.vue
Normal file
363
frontend/src/components/ConnectionDropdown.vue
Normal 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>
|
||||
@@ -19,17 +19,166 @@ const isOpen = computed({
|
||||
})
|
||||
|
||||
const terminalContainer = ref<HTMLElement | null>(null)
|
||||
const terminalRef = ref<HTMLElement | null>(null)
|
||||
const connected = ref(false)
|
||||
const connecting = ref(false)
|
||||
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 fitAddon: FitAddon | null = null
|
||||
let socket: WebSocket | 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() {
|
||||
if (!terminalContainer.value || terminal) return
|
||||
@@ -37,30 +186,30 @@ function initTerminal() {
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
fontSize: 13,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
||||
fontSize: 12,
|
||||
fontFamily: "'Consolas', 'Lucida Console', monospace",
|
||||
theme: {
|
||||
background: '#0f0f14',
|
||||
foreground: '#e4e4e7',
|
||||
cursor: '#6366f1',
|
||||
cursorAccent: '#0f0f14',
|
||||
selectionBackground: 'rgba(99, 102, 241, 0.3)',
|
||||
black: '#16161d',
|
||||
red: '#ef4444',
|
||||
green: '#22c55e',
|
||||
yellow: '#eab308',
|
||||
blue: '#3b82f6',
|
||||
magenta: '#a855f7',
|
||||
cyan: '#06b6d4',
|
||||
white: '#e4e4e7',
|
||||
brightBlack: '#52525b',
|
||||
brightRed: '#f87171',
|
||||
brightGreen: '#4ade80',
|
||||
brightYellow: '#facc15',
|
||||
brightBlue: '#60a5fa',
|
||||
brightMagenta: '#c084fc',
|
||||
brightCyan: '#22d3ee',
|
||||
brightWhite: '#ffffff'
|
||||
background: 'rgba(12, 12, 12, 0.95)',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: 'rgba(100, 150, 255, 0.4)',
|
||||
black: '#0c0c0c',
|
||||
red: '#c50f1f',
|
||||
green: '#13a10e',
|
||||
yellow: '#c19c00',
|
||||
blue: '#0037da',
|
||||
magenta: '#881798',
|
||||
cyan: '#3a96dd',
|
||||
white: '#cccccc',
|
||||
brightBlack: '#767676',
|
||||
brightRed: '#e74856',
|
||||
brightGreen: '#16c60c',
|
||||
brightYellow: '#f9f1a5',
|
||||
brightBlue: '#3b78ff',
|
||||
brightMagenta: '#b4009e',
|
||||
brightCyan: '#61d6d6',
|
||||
brightWhite: '#f2f2f2'
|
||||
},
|
||||
allowProposedApi: true
|
||||
})
|
||||
@@ -68,15 +217,12 @@ function initTerminal() {
|
||||
fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.loadAddon(new WebLinksAddon())
|
||||
|
||||
terminal.open(terminalContainer.value)
|
||||
|
||||
nextTick(() => {
|
||||
fitAddon?.fit()
|
||||
})
|
||||
nextTick(() => fitAddon?.fit())
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (fitAddon && terminal && !isMinimized.value) {
|
||||
if (fitAddon && terminal) {
|
||||
fitAddon.fit()
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
@@ -94,11 +240,20 @@ function initTerminal() {
|
||||
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() {
|
||||
if (connecting.value || connected.value) return
|
||||
|
||||
connecting.value = true
|
||||
|
||||
try {
|
||||
@@ -108,7 +263,6 @@ async function connect() {
|
||||
connected.value = true
|
||||
connecting.value = false
|
||||
terminal?.focus()
|
||||
|
||||
if (terminal) {
|
||||
socket?.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
@@ -123,7 +277,7 @@ async function connect() {
|
||||
if (msg.type === 'connected') {
|
||||
sessionId.value = msg.sessionId
|
||||
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') {
|
||||
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() {
|
||||
isOpen.value = false
|
||||
}
|
||||
@@ -178,24 +314,30 @@ function runClaude() {
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for open state
|
||||
watch(isOpen, async (open) => {
|
||||
if (open) {
|
||||
await nextTick()
|
||||
if (!terminal) {
|
||||
initTerminal()
|
||||
}
|
||||
if (!connected.value && !connecting.value) {
|
||||
connect()
|
||||
}
|
||||
if (!connected.value && !connecting.value) connect()
|
||||
nextTick(() => {
|
||||
fitAddon?.fit()
|
||||
terminal?.focus()
|
||||
})
|
||||
} else {
|
||||
// Cleanup when closing
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
terminal?.dispose()
|
||||
terminal = null
|
||||
fitAddon = null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Global listeners for Ctrl+E
|
||||
document.addEventListener('mousemove', trackMouse)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
initTerminal()
|
||||
@@ -207,46 +349,77 @@ onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
socket?.close()
|
||||
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>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="terminal-slide">
|
||||
<div v-if="isOpen" class="floating-terminal" :class="{ minimized: isMinimized }">
|
||||
<!-- Header -->
|
||||
<div class="terminal-header" @dblclick="toggleMinimize">
|
||||
<div class="header-left">
|
||||
<div class="traffic-lights">
|
||||
<button class="light red" @click="close" title="Close"></button>
|
||||
<button class="light yellow" @click="toggleMinimize" title="Minimize"></button>
|
||||
<button class="light green" @click="runClaude" title="Run Claude"></button>
|
||||
<Transition name="win-slide">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="terminalRef"
|
||||
class="aero-win"
|
||||
:class="{ dragging: isDragging, resizing: isResizing }"
|
||||
:style="terminalStyle"
|
||||
>
|
||||
<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>
|
||||
<span class="terminal-title">
|
||||
Terminal
|
||||
<span v-if="sessionId" class="session-id">{{ sessionId }}</span>
|
||||
</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 class="window-controls">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal body -->
|
||||
<div v-show="!isMinimized" class="terminal-body">
|
||||
<div ref="terminalContainer" class="terminal-container"></div>
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<div ref="terminalContainer" class="term"></div>
|
||||
</div>
|
||||
|
||||
<!-- Minimized bar -->
|
||||
<div v-if="isMinimized" class="minimized-bar" @click="toggleMinimize">
|
||||
<span>Click to expand</span>
|
||||
<!-- Resize handle -->
|
||||
<div class="resize-handle" @mousedown="startResize"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -254,237 +427,157 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.floating-terminal {
|
||||
.aero-win {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 700px;
|
||||
height: 450px;
|
||||
background: #0f0f14;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
min-width: 400px;
|
||||
min-height: 250px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.glass {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
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;
|
||||
z-index: 9999;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.floating-terminal.minimized {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.terminal-header {
|
||||
.titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #16161d;
|
||||
border-bottom: 1px solid #2a2a3a;
|
||||
cursor: default;
|
||||
height: 22px;
|
||||
padding: 0 2px 0 6px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.3);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.aero-win.dragging .titlebar { cursor: grabbing; }
|
||||
|
||||
.header-left {
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 5px;
|
||||
color: #222;
|
||||
font: 500 10px/1 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.traffic-lights {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
.dot {
|
||||
width: 5px; height: 5px;
|
||||
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;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.link:hover { color: #47a; }
|
||||
|
||||
.light:hover {
|
||||
opacity: 0.8;
|
||||
.window-controls {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.light.red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.window-controls button {
|
||||
width: 20px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
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;
|
||||
justify-content: center;
|
||||
background: rgba(255,255,255,0.3);
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 2px;
|
||||
color: #333;
|
||||
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) {
|
||||
background: #818cf8;
|
||||
}
|
||||
|
||||
.btn-connect:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Terminal body */
|
||||
.terminal-body {
|
||||
.content {
|
||||
flex: 1;
|
||||
margin: 2px;
|
||||
border-radius: 2px;
|
||||
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%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm) {
|
||||
.term :deep(.xterm) {
|
||||
height: 100%;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport) {
|
||||
.term :deep(.xterm-viewport) {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
.term :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background: #2a2a3a;
|
||||
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Minimized bar */
|
||||
.minimized-bar {
|
||||
padding: 8px 16px;
|
||||
text-align: center;
|
||||
color: #52525b;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.minimized-bar:hover {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
.win-slide-enter-active, .win-slide-leave-active { transition: all .15s ease; }
|
||||
.win-slide-enter-from, .win-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
|
||||
|
||||
/* Transition */
|
||||
.terminal-slide-enter-active,
|
||||
.terminal-slide-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
|
||||
|
||||
.terminal-slide-enter-from,
|
||||
.terminal-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(100px) scale(0.95);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@media (max-width: 640px) {
|
||||
.aero-win {
|
||||
inset: auto 0 0 0 !important;
|
||||
width: 100% !important;
|
||||
height: 55% !important;
|
||||
}
|
||||
.glass { border-radius: 6px 6px 0 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
218
frontend/src/components/PwaInstallBanner.vue
Normal file
218
frontend/src/components/PwaInstallBanner.vue
Normal 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>
|
||||
@@ -125,6 +125,12 @@ onMounted(() => {
|
||||
<line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</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 class="toolbar-divider"></div>
|
||||
|
||||
392
frontend/src/components/ToolsDropdown.vue
Normal file
392
frontend/src/components/ToolsDropdown.vue
Normal 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>
|
||||
220
frontend/src/components/database/DataTable.vue
Normal file
220
frontend/src/components/database/DataTable.vue
Normal 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>
|
||||
213
frontend/src/components/database/DatabaseSidebar.vue
Normal file
213
frontend/src/components/database/DatabaseSidebar.vue
Normal 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>
|
||||
184
frontend/src/components/database/DatabaseStats.vue
Normal file
184
frontend/src/components/database/DatabaseStats.vue
Normal 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>
|
||||
171
frontend/src/components/database/FilterBar.vue
Normal file
171
frontend/src/components/database/FilterBar.vue
Normal 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>
|
||||
83
frontend/src/components/database/QueryColumnsBar.vue
Normal file
83
frontend/src/components/database/QueryColumnsBar.vue
Normal 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>
|
||||
96
frontend/src/components/database/QueryEditor.vue
Normal file
96
frontend/src/components/database/QueryEditor.vue
Normal 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>
|
||||
115
frontend/src/components/database/SchemaInfo.vue
Normal file
115
frontend/src/components/database/SchemaInfo.vue
Normal 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>
|
||||
76
frontend/src/components/database/TablePagination.vue
Normal file
76
frontend/src/components/database/TablePagination.vue
Normal 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>
|
||||
8
frontend/src/components/database/index.ts
Normal file
8
frontend/src/components/database/index.ts
Normal 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'
|
||||
3
frontend/src/composables/database/index.ts
Normal file
3
frontend/src/composables/database/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useDatabaseApi } from './useDatabaseApi'
|
||||
export { useDataTable, type UseDataTableOptions, type UseDataTableReturn } from './useDataTable'
|
||||
export { useQueryExecutor } from './useQueryExecutor'
|
||||
158
frontend/src/composables/database/useDataTable.ts
Normal file
158
frontend/src/composables/database/useDataTable.ts
Normal 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
|
||||
}
|
||||
}
|
||||
80
frontend/src/composables/database/useDatabaseApi.ts
Normal file
80
frontend/src/composables/database/useDatabaseApi.ts
Normal 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
|
||||
}
|
||||
}
|
||||
53
frontend/src/composables/database/useQueryExecutor.ts
Normal file
53
frontend/src/composables/database/useQueryExecutor.ts
Normal 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
@@ -17,7 +17,7 @@ let fitAddon: FitAddon | null = null
|
||||
let socket: WebSocket | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const WS_URL = 'ws://localhost:4103'
|
||||
const WS_URL = `ws://${window.location.hostname}:4103`
|
||||
|
||||
function initTerminal() {
|
||||
if (!terminalContainer.value) return
|
||||
|
||||
595
frontend/src/pages/ToolsPage.vue
Normal file
595
frontend/src/pages/ToolsPage.vue
Normal 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>
|
||||
@@ -48,6 +48,11 @@ const router = createRouter({
|
||||
path: '/terminal',
|
||||
name: 'terminal',
|
||||
component: () => import('../pages/TerminalPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/tools',
|
||||
name: 'tools',
|
||||
component: () => import('../pages/ToolsPage.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1,123 +1,159 @@
|
||||
import { clearAllTools } from './webmcp'
|
||||
import {
|
||||
registerCanvasTools,
|
||||
unregisterCanvasTools,
|
||||
CANVAS_TOOLS
|
||||
} 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'
|
||||
/**
|
||||
* Tool Registry - Single source of truth for MCP tool management
|
||||
*
|
||||
* All tool registration/unregistration MUST go through this module.
|
||||
* Other modules should NOT directly use webmcp registration functions.
|
||||
*/
|
||||
|
||||
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 {
|
||||
register: () => void
|
||||
unregister: () => void
|
||||
toolNames: string[]
|
||||
export type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools'
|
||||
|
||||
// Internal webmcp functions (not exported for external use)
|
||||
let webmcpInstance: any = null
|
||||
const registeredToolsSet = new Set<string>()
|
||||
|
||||
async function internalRegisterTool(config: ToolConfig): Promise<boolean> {
|
||||
if (!webmcpInstance) {
|
||||
webmcpInstance = await initWebMCP()
|
||||
}
|
||||
|
||||
if (registeredToolsSet.has(config.name)) {
|
||||
return false // Already registered
|
||||
}
|
||||
|
||||
webmcpInstance.registerTool(config.name, config.description, config.schema, config.handler)
|
||||
registeredToolsSet.add(config.name)
|
||||
console.log(`[ToolRegistry] Registered: ${config.name}`)
|
||||
return true
|
||||
}
|
||||
|
||||
const pageTools: Record<PageName, PageToolSet> = {
|
||||
home: {
|
||||
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: []
|
||||
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 isInitialized = false
|
||||
|
||||
/**
|
||||
* Inicializa el registry con el router de Vue
|
||||
* Initialize the tool registry with Vue router
|
||||
*/
|
||||
export function initToolRegistry(router: any) {
|
||||
setRouter(router)
|
||||
@@ -125,80 +161,197 @@ export function initToolRegistry(router: any) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Activa las tools para una página específica.
|
||||
* Desregistra las tools de otras páginas primero.
|
||||
* Set Gitea credentials for source code tools
|
||||
*/
|
||||
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) {
|
||||
console.warn('[ToolRegistry] Not initialized. Call initToolRegistry first.')
|
||||
console.warn('[ToolRegistry] Not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
// Si ya estamos en esta página, no hacer nada
|
||||
if (currentPage === pageName) {
|
||||
console.log(`[ToolRegistry] Already on page "${pageName}", skipping`)
|
||||
console.log(`[ToolRegistry] Already on "${pageName}", skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[ToolRegistry] Switching from "${currentPage}" to "${pageName}"`)
|
||||
|
||||
// Desregistrar tools de la página anterior
|
||||
if (currentPage && pageTools[currentPage]) {
|
||||
pageTools[currentPage].unregister()
|
||||
const toolsStore = useToolsStore()
|
||||
const pinnedTools = toolsStore.getPinnedToolNames()
|
||||
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
|
||||
if (pageTools[pageName]) {
|
||||
pageTools[pageName].register()
|
||||
// Register new tools
|
||||
for (const toolName of newTools) {
|
||||
const config = configs.get(toolName)
|
||||
if (config) {
|
||||
await internalRegisterTool(config)
|
||||
}
|
||||
}
|
||||
|
||||
// Asegurar que las tools globales estén registradas
|
||||
registerGlobalTools()
|
||||
// Ensure pinned tools are registered
|
||||
for (const toolName of pinnedTools) {
|
||||
const config = configs.get(toolName)
|
||||
if (config && !registeredToolsSet.has(toolName)) {
|
||||
await internalRegisterTool(config)
|
||||
}
|
||||
}
|
||||
|
||||
currentPage = pageName
|
||||
syncStoreWithActiveTools()
|
||||
|
||||
console.log(`[ToolRegistry] Page "${pageName}" tools activated`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializa las tools para un refresh de página.
|
||||
* Limpia todo y registra las tools correctas.
|
||||
* Initialize tools on page refresh
|
||||
*/
|
||||
export function initToolsOnRefresh(pageName: PageName) {
|
||||
export async function initToolsOnRefresh(pageName: PageName) {
|
||||
if (!isInitialized) {
|
||||
console.warn('[ToolRegistry] Not initialized. Call initToolRegistry first.')
|
||||
console.warn('[ToolRegistry] Not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[ToolRegistry] Initializing on refresh for page "${pageName}"`)
|
||||
console.log(`[ToolRegistry] Initializing on refresh for "${pageName}"`)
|
||||
|
||||
// Limpiar todas las tools existentes
|
||||
clearAllTools()
|
||||
|
||||
// Reset current page tracking
|
||||
internalClearAllTools()
|
||||
currentPage = null
|
||||
|
||||
// Activar tools de la página actual
|
||||
activatePageTools(pageName)
|
||||
await 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 {
|
||||
return currentPage
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene los nombres de las tools para una página
|
||||
* Get tool names for a page
|
||||
*/
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
141
frontend/src/services/tools/handlers/canvasHandlers.ts
Normal file
141
frontend/src/services/tools/handlers/canvasHandlers.ts
Normal 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`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
135
frontend/src/services/tools/handlers/componentHandlers.ts
Normal file
135
frontend/src/services/tools/handlers/componentHandlers.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
186
frontend/src/services/tools/handlers/databaseHandlers.ts
Normal file
186
frontend/src/services/tools/handlers/databaseHandlers.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
213
frontend/src/services/tools/handlers/globalHandlers.ts
Normal file
213
frontend/src/services/tools/handlers/globalHandlers.ts
Normal 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.`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
24
frontend/src/services/tools/handlers/index.ts
Normal file
24
frontend/src/services/tools/handlers/index.ts
Normal 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
|
||||
}
|
||||
223
frontend/src/services/tools/handlers/projectCanvasHandlers.ts
Normal file
223
frontend/src/services/tools/handlers/projectCanvasHandlers.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
238
frontend/src/services/tools/handlers/sourceCodeHandlers.ts
Normal file
238
frontend/src/services/tools/handlers/sourceCodeHandlers.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
119
frontend/src/services/tools/handlers/terminalHandlers.ts
Normal file
119
frontend/src/services/tools/handlers/terminalHandlers.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
211
frontend/src/services/tools/handlers/themeHandlers.ts
Normal file
211
frontend/src/services/tools/handlers/themeHandlers.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
95
frontend/src/services/tools/toolDefinitions.ts
Normal file
95
frontend/src/services/tools/toolDefinitions.ts
Normal 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' }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCanvasStore } from '../stores/canvas'
|
||||
|
||||
let webmcpInstance: any = null
|
||||
const registeredTools = new Set<string>()
|
||||
const eventUnsubscribers: Array<() => void> = []
|
||||
|
||||
const API_BASE = 'http://localhost:4101'
|
||||
let tokenPollingInterval: number | null = null
|
||||
@@ -13,31 +14,130 @@ export async function initWebMCP() {
|
||||
const WebMCP = WebMCPModule.default || WebMCPModule
|
||||
|
||||
webmcpInstance = new WebMCP({
|
||||
color: '#6366f1',
|
||||
position: 'bottom-right',
|
||||
headless: true,
|
||||
inactivityTimeout: 60 * 60 * 1000 // 1 hora
|
||||
})
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
webmcpInstance.on?.('connected', () => {
|
||||
canvasStore.setConnected(true)
|
||||
})
|
||||
|
||||
webmcpInstance.on?.('disconnected', () => {
|
||||
canvasStore.setConnected(false)
|
||||
})
|
||||
setupEventHandlers()
|
||||
|
||||
// Check initial connection state
|
||||
if (webmcpInstance.isConnected) {
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.setConnected(true)
|
||||
updateConnectionInfo()
|
||||
}
|
||||
|
||||
// Exponer globalmente para debug
|
||||
// Expose globally for debug
|
||||
;(window as any).webmcp = 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() {
|
||||
return webmcpInstance
|
||||
}
|
||||
@@ -91,6 +191,26 @@ export function clearAllTools() {
|
||||
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[] {
|
||||
return [...registeredTools]
|
||||
}
|
||||
@@ -151,27 +271,27 @@ export function parseToken(token: string): { server: string; token: string } | n
|
||||
}
|
||||
|
||||
export async function connectWithToken(token: string): Promise<boolean> {
|
||||
const parsed = parseToken(token)
|
||||
if (!parsed) return false
|
||||
if (!webmcpInstance) {
|
||||
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
|
||||
localStorage.setItem('webmcp_token', token)
|
||||
console.log('[WebMCP] Connecting with token...')
|
||||
|
||||
// Clear the pending token from server
|
||||
await clearToken()
|
||||
|
||||
// If webmcp is already initialized, try to reconnect
|
||||
if (webmcpInstance && typeof webmcpInstance.connect === 'function') {
|
||||
// Connect passing the token directly
|
||||
try {
|
||||
await webmcpInstance.connect()
|
||||
await webmcpInstance.connect(token)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('[WebMCP] Failed to connect:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface HistoryEntry {
|
||||
tool: string
|
||||
@@ -14,16 +14,60 @@ interface Notification {
|
||||
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', () => {
|
||||
// Connection state
|
||||
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 notifications = ref<Notification[]>([])
|
||||
const showHistoryPanel = ref(false)
|
||||
|
||||
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) {
|
||||
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) {
|
||||
@@ -60,11 +104,23 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
// Connection state
|
||||
isConnected,
|
||||
isReconnecting,
|
||||
connectionStatus,
|
||||
connectionError,
|
||||
connectionInfo,
|
||||
statusColor,
|
||||
// History & UI
|
||||
history,
|
||||
notifications,
|
||||
showHistoryPanel,
|
||||
// Actions
|
||||
setConnected,
|
||||
setReconnecting,
|
||||
setConnectionStatus,
|
||||
setConnectionError,
|
||||
setConnectionInfo,
|
||||
addToHistory,
|
||||
clearHistory,
|
||||
showNotification,
|
||||
|
||||
170
frontend/src/stores/tools.ts
Normal file
170
frontend/src/stores/tools.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -18,15 +18,20 @@
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
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);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
|
||||
23
frontend/src/types/database.ts
Normal file
23
frontend/src/types/database.ts
Normal 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
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
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({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
// Habilitar compilación de templates en runtime para componentes dinámicos
|
||||
'vue': 'vue/dist/vue.esm-bundler.js'
|
||||
}
|
||||
@@ -13,15 +18,36 @@ export default defineConfig({
|
||||
vue(),
|
||||
VitePWA({
|
||||
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: {
|
||||
name: 'Agent UI - Dynamic Canvas',
|
||||
name: 'Agent UI',
|
||||
short_name: 'AgentUI',
|
||||
description: 'Dynamic canvas for Claude Code interaction',
|
||||
theme_color: '#1a1a2e',
|
||||
background_color: '#1a1a2e',
|
||||
description: 'Dynamic canvas for Claude Code interaction via WebMCP',
|
||||
theme_color: '#16161d',
|
||||
background_color: '#0f0f14',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
categories: ['developer', 'utilities'],
|
||||
icons: [
|
||||
{
|
||||
src: 'icons/icon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any'
|
||||
},
|
||||
{
|
||||
src: 'icons/icon-192.png',
|
||||
sizes: '192x192',
|
||||
@@ -31,6 +57,12 @@ export default defineConfig({
|
||||
src: 'icons/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'icons/icon-maskable-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -39,6 +71,8 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 4100,
|
||||
host: true,
|
||||
allowedHosts: ['z590.interno.com', 'localhost'],
|
||||
cors: true,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:4101'
|
||||
},
|
||||
|
||||
15
server/bun.lock
Normal file
15
server/bun.lock
Normal 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
13
server/config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Server configuration
|
||||
export const PORT_HTTP = 4101
|
||||
export const PORT_TERMINAL = 4103
|
||||
|
||||
// Terminal configuration
|
||||
export const WORKING_DIR = process.cwd().replace(/[\\\/]server$/, '')
|
||||
export const SHELL = process.platform === 'win32' ? 'powershell.exe' : 'bash'
|
||||
export const SHELL_ARGS = process.platform === 'win32' ? ['-NoLogo', '-NoProfile'] : []
|
||||
export const DEFAULT_SESSION_ID = 'main'
|
||||
export const MAX_BUFFER_LINES = 1000
|
||||
|
||||
// Database
|
||||
export const DB_PATH = 'agent-ui.db'
|
||||
16
server/db/index.ts
Normal file
16
server/db/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { DB_PATH } from '../config'
|
||||
import { runMigrations } from './migrations'
|
||||
import { runSeeds } from './seeds'
|
||||
|
||||
// Create database instance
|
||||
export const db = new Database(DB_PATH)
|
||||
|
||||
// Initialize database
|
||||
export function initDatabase() {
|
||||
runMigrations(db)
|
||||
runSeeds(db)
|
||||
console.log('[DB] SQLite initialized:', DB_PATH)
|
||||
}
|
||||
|
||||
export { Database }
|
||||
107
server/db/migrations.ts
Normal file
107
server/db/migrations.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Database } from 'bun:sqlite'
|
||||
|
||||
export function runMigrations(db: Database) {
|
||||
// History table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
tool_name TEXT NOT NULL,
|
||||
args TEXT,
|
||||
result TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
// Config table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
// Vue components table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS vue_components (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
template TEXT NOT NULL,
|
||||
setup TEXT,
|
||||
style TEXT,
|
||||
props TEXT,
|
||||
imports TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
// Themes table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS themes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
is_system INTEGER DEFAULT 0,
|
||||
variables TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
// Project canvas table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS project_canvas (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'project',
|
||||
theme_id TEXT,
|
||||
config TEXT,
|
||||
tools TEXT,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
is_system INTEGER DEFAULT 0,
|
||||
show_in_toolbar INTEGER DEFAULT 0,
|
||||
toolbar_icon TEXT,
|
||||
toolbar_order INTEGER DEFAULT 99,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
// Canvas-components relation table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS canvas_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
canvas_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
position INTEGER DEFAULT 0,
|
||||
props TEXT,
|
||||
layout TEXT,
|
||||
is_visible INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(canvas_id, component_id)
|
||||
)
|
||||
`)
|
||||
|
||||
// Run column migrations for existing tables
|
||||
runColumnMigrations(db)
|
||||
}
|
||||
|
||||
function runColumnMigrations(db: Database) {
|
||||
// Add toolbar columns to project_canvas if missing
|
||||
const alterStatements = [
|
||||
'ALTER TABLE project_canvas ADD COLUMN show_in_toolbar INTEGER DEFAULT 0',
|
||||
'ALTER TABLE project_canvas ADD COLUMN toolbar_icon TEXT',
|
||||
'ALTER TABLE project_canvas ADD COLUMN toolbar_order INTEGER DEFAULT 99'
|
||||
]
|
||||
|
||||
for (const sql of alterStatements) {
|
||||
try {
|
||||
db.run(sql)
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
}
|
||||
}
|
||||
134
server/db/seeds.ts
Normal file
134
server/db/seeds.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { Database } from 'bun:sqlite'
|
||||
|
||||
const darkTheme = {
|
||||
id: 'theme-dark',
|
||||
name: 'Dark',
|
||||
description: 'Default dark theme',
|
||||
is_default: 1,
|
||||
is_system: 1,
|
||||
variables: JSON.stringify({
|
||||
colors: {
|
||||
'bg-primary': '#0f0f14',
|
||||
'bg-secondary': '#16161d',
|
||||
'bg-hover': '#1e1e28',
|
||||
'bg-tertiary': '#252530',
|
||||
'border-color': '#2a2a3a',
|
||||
'border-hover': '#3a3a4a'
|
||||
},
|
||||
text: {
|
||||
'text-primary': '#e4e4e7',
|
||||
'text-secondary': '#a1a1aa',
|
||||
'text-muted': '#52525b',
|
||||
'text-inverse': '#0f0f14'
|
||||
},
|
||||
accent: {
|
||||
'accent': '#6366f1',
|
||||
'accent-hover': '#818cf8',
|
||||
'accent-muted': 'rgba(99, 102, 241, 0.2)',
|
||||
'accent-text': '#ffffff'
|
||||
},
|
||||
semantic: {
|
||||
'success': '#22c55e',
|
||||
'success-bg': 'rgba(34, 197, 94, 0.1)',
|
||||
'warning': '#eab308',
|
||||
'warning-bg': 'rgba(234, 179, 8, 0.1)',
|
||||
'error': '#ef4444',
|
||||
'error-bg': 'rgba(239, 68, 68, 0.1)',
|
||||
'info': '#3b82f6',
|
||||
'info-bg': 'rgba(59, 130, 246, 0.1)'
|
||||
},
|
||||
spacing: {
|
||||
'radius-sm': '4px',
|
||||
'radius-md': '8px',
|
||||
'radius-lg': '12px',
|
||||
'radius-full': '9999px'
|
||||
},
|
||||
typography: {
|
||||
'font-sans': "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
'font-mono': "'JetBrains Mono', 'Fira Code', Consolas, monospace"
|
||||
},
|
||||
effects: {
|
||||
'shadow-sm': '0 1px 2px rgba(0,0,0,0.2)',
|
||||
'shadow-md': '0 4px 6px rgba(0,0,0,0.3)',
|
||||
'shadow-lg': '0 10px 15px rgba(0,0,0,0.4)',
|
||||
'transition-fast': '0.15s ease',
|
||||
'transition-normal': '0.2s ease'
|
||||
}
|
||||
}),
|
||||
metadata: JSON.stringify({ author: 'system', version: '1.0.0', tags: ['dark', 'default'] })
|
||||
}
|
||||
|
||||
const lightTheme = {
|
||||
id: 'theme-light',
|
||||
name: 'Light',
|
||||
description: 'Clean light theme',
|
||||
is_default: 0,
|
||||
is_system: 1,
|
||||
variables: JSON.stringify({
|
||||
colors: {
|
||||
'bg-primary': '#ffffff',
|
||||
'bg-secondary': '#f4f4f5',
|
||||
'bg-hover': '#e4e4e7',
|
||||
'bg-tertiary': '#d4d4d8',
|
||||
'border-color': '#d4d4d8',
|
||||
'border-hover': '#a1a1aa'
|
||||
},
|
||||
text: {
|
||||
'text-primary': '#18181b',
|
||||
'text-secondary': '#52525b',
|
||||
'text-muted': '#a1a1aa',
|
||||
'text-inverse': '#ffffff'
|
||||
},
|
||||
accent: {
|
||||
'accent': '#4f46e5',
|
||||
'accent-hover': '#4338ca',
|
||||
'accent-muted': 'rgba(79, 70, 229, 0.1)',
|
||||
'accent-text': '#ffffff'
|
||||
},
|
||||
semantic: {
|
||||
'success': '#16a34a',
|
||||
'success-bg': 'rgba(22, 163, 74, 0.1)',
|
||||
'warning': '#ca8a04',
|
||||
'warning-bg': 'rgba(202, 138, 4, 0.1)',
|
||||
'error': '#dc2626',
|
||||
'error-bg': 'rgba(220, 38, 38, 0.1)',
|
||||
'info': '#2563eb',
|
||||
'info-bg': 'rgba(37, 99, 235, 0.1)'
|
||||
},
|
||||
spacing: {
|
||||
'radius-sm': '4px',
|
||||
'radius-md': '8px',
|
||||
'radius-lg': '12px',
|
||||
'radius-full': '9999px'
|
||||
},
|
||||
typography: {
|
||||
'font-sans': "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
'font-mono': "'JetBrains Mono', 'Fira Code', Consolas, monospace"
|
||||
},
|
||||
effects: {
|
||||
'shadow-sm': '0 1px 2px rgba(0,0,0,0.05)',
|
||||
'shadow-md': '0 4px 6px rgba(0,0,0,0.07)',
|
||||
'shadow-lg': '0 10px 15px rgba(0,0,0,0.1)',
|
||||
'transition-fast': '0.15s ease',
|
||||
'transition-normal': '0.2s ease'
|
||||
}
|
||||
}),
|
||||
metadata: JSON.stringify({ author: 'system', version: '1.0.0', tags: ['light'] })
|
||||
}
|
||||
|
||||
export function runSeeds(db: Database) {
|
||||
// Check if system themes exist
|
||||
const existingThemes = db.query('SELECT COUNT(*) as count FROM themes WHERE is_system = 1').get() as { count: number }
|
||||
|
||||
if (existingThemes.count === 0) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO themes (id, name, description, is_default, is_system, variables, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
stmt.run(darkTheme.id, darkTheme.name, darkTheme.description, darkTheme.is_default, darkTheme.is_system, darkTheme.variables, darkTheme.metadata)
|
||||
stmt.run(lightTheme.id, lightTheme.name, lightTheme.description, lightTheme.is_default, lightTheme.is_system, lightTheme.variables, lightTheme.metadata)
|
||||
|
||||
console.log('[DB] System themes created')
|
||||
}
|
||||
}
|
||||
1397
server/index.ts
1397
server/index.ts
File diff suppressed because it is too large
Load Diff
31
server/package-lock.json
generated
Normal file
31
server/package-lock.json
generated
Normal 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
250
server/routes/canvas.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { db } from '../db'
|
||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||
|
||||
function parseCanvas(row: any) {
|
||||
return {
|
||||
...row,
|
||||
is_default: !!row.is_default,
|
||||
is_system: !!row.is_system,
|
||||
show_in_toolbar: !!row.show_in_toolbar,
|
||||
config: row.config ? JSON.parse(row.config) : null,
|
||||
tools: row.tools ? JSON.parse(row.tools) : []
|
||||
}
|
||||
}
|
||||
|
||||
export function handleToolbarCanvas() {
|
||||
const rows = db.query('SELECT * FROM project_canvas WHERE show_in_toolbar = 1 ORDER BY toolbar_order ASC, name ASC').all()
|
||||
return jsonResponse((rows as any[]).map(parseCanvas))
|
||||
}
|
||||
|
||||
export function handleDefaultCanvas() {
|
||||
const row = db.query('SELECT * FROM project_canvas WHERE is_default = 1 LIMIT 1').get() as any
|
||||
if (!row) {
|
||||
return jsonResponse({ hasDefault: false })
|
||||
}
|
||||
return jsonResponse({ hasDefault: true, canvas: parseCanvas(row) })
|
||||
}
|
||||
|
||||
export async function handleCanvas(req: Request) {
|
||||
if (req.method === 'GET') {
|
||||
const rows = db.query('SELECT * FROM project_canvas ORDER BY is_system DESC, is_default DESC, name ASC').all()
|
||||
return jsonResponse((rows as any[]).map(parseCanvas))
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json()
|
||||
const id = body.id || `canvas-${Date.now()}`
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO project_canvas
|
||||
(id, name, description, type, theme_id, config, tools, is_default, is_system, show_in_toolbar, toolbar_icon, toolbar_order, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`)
|
||||
stmt.run(
|
||||
id,
|
||||
body.name,
|
||||
body.description || '',
|
||||
body.type || 'project',
|
||||
body.theme_id || null,
|
||||
JSON.stringify(body.config || {}),
|
||||
JSON.stringify(body.tools || []),
|
||||
body.is_default ? 1 : 0,
|
||||
body.is_system ? 1 : 0,
|
||||
body.show_in_toolbar ? 1 : 0,
|
||||
body.toolbar_icon || null,
|
||||
body.toolbar_order ?? 99
|
||||
)
|
||||
return jsonResponse({ success: true, id })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function handleCanvasById(req: Request, id: string, action?: string) {
|
||||
// POST /api/canvas/:id/clone
|
||||
if (action === 'clone' && req.method === 'POST') {
|
||||
const original = db.query('SELECT * FROM project_canvas WHERE id = ?').get(id) as any
|
||||
if (!original) {
|
||||
return errorResponse('Canvas not found', 404)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const newId = `canvas-${Date.now()}`
|
||||
const newName = body.name || `${original.name} (copia)`
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO project_canvas
|
||||
(id, name, description, type, theme_id, config, tools, is_default, is_system)
|
||||
VALUES (?, ?, ?, 'project', ?, ?, ?, 0, 0)
|
||||
`)
|
||||
stmt.run(newId, newName, original.description, original.theme_id, original.config, original.tools)
|
||||
|
||||
// Clone canvas components
|
||||
const components = db.query('SELECT * FROM canvas_components WHERE canvas_id = ?').all(id) as any[]
|
||||
if (components.length > 0) {
|
||||
const compStmt = db.prepare(`
|
||||
INSERT INTO canvas_components (canvas_id, component_id, position, props, layout, is_visible)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const comp of components) {
|
||||
compStmt.run(newId, comp.component_id, comp.position, comp.props, comp.layout, comp.is_visible)
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, id: newId })
|
||||
}
|
||||
|
||||
// GET /api/canvas/:id
|
||||
if (req.method === 'GET' && !action) {
|
||||
const row = db.query('SELECT * FROM project_canvas WHERE id = ?').get(id) as any
|
||||
if (!row) {
|
||||
return errorResponse('Canvas not found', 404)
|
||||
}
|
||||
return jsonResponse(parseCanvas(row))
|
||||
}
|
||||
|
||||
// PUT /api/canvas/:id
|
||||
if (req.method === 'PUT' && !action) {
|
||||
const canvas = db.query('SELECT * FROM project_canvas WHERE id = ?').get(id) as any
|
||||
if (!canvas) {
|
||||
return errorResponse('Canvas not found', 404)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
|
||||
// System canvas can only modify toolbar settings and is_default
|
||||
if (canvas.is_system) {
|
||||
if (body.is_default !== undefined) { updates.push('is_default = ?'); values.push(body.is_default ? 1 : 0) }
|
||||
if (body.show_in_toolbar !== undefined) { updates.push('show_in_toolbar = ?'); values.push(body.show_in_toolbar ? 1 : 0) }
|
||||
if (body.toolbar_icon !== undefined) { updates.push('toolbar_icon = ?'); values.push(body.toolbar_icon) }
|
||||
if (body.toolbar_order !== undefined) { updates.push('toolbar_order = ?'); values.push(body.toolbar_order) }
|
||||
} else {
|
||||
if (body.name !== undefined) { updates.push('name = ?'); values.push(body.name) }
|
||||
if (body.description !== undefined) { updates.push('description = ?'); values.push(body.description) }
|
||||
if (body.theme_id !== undefined) { updates.push('theme_id = ?'); values.push(body.theme_id) }
|
||||
if (body.config !== undefined) { updates.push('config = ?'); values.push(JSON.stringify(body.config)) }
|
||||
if (body.tools !== undefined) { updates.push('tools = ?'); values.push(JSON.stringify(body.tools)) }
|
||||
if (body.is_default !== undefined) { updates.push('is_default = ?'); values.push(body.is_default ? 1 : 0) }
|
||||
if (body.show_in_toolbar !== undefined) { updates.push('show_in_toolbar = ?'); values.push(body.show_in_toolbar ? 1 : 0) }
|
||||
if (body.toolbar_icon !== undefined) { updates.push('toolbar_icon = ?'); values.push(body.toolbar_icon) }
|
||||
if (body.toolbar_order !== undefined) { updates.push('toolbar_order = ?'); values.push(body.toolbar_order) }
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP')
|
||||
values.push(id)
|
||||
const sql = `UPDATE project_canvas SET ${updates.join(', ')} WHERE id = ?`
|
||||
db.run(sql, values)
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, id })
|
||||
}
|
||||
|
||||
// DELETE /api/canvas/:id
|
||||
if (req.method === 'DELETE' && !action) {
|
||||
const canvas = db.query('SELECT is_system FROM project_canvas WHERE id = ?').get(id) as { is_system: number } | null
|
||||
if (canvas?.is_system) {
|
||||
return errorResponse('Cannot delete system canvas', 403)
|
||||
}
|
||||
db.run('DELETE FROM project_canvas WHERE id = ?', [id])
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Canvas Components API
|
||||
export async function handleCanvasComponents(req: Request, canvasId: string) {
|
||||
if (req.method === 'GET') {
|
||||
const rows = db.query(`
|
||||
SELECT cc.*, vc.name, vc.template, vc.setup, vc.style, vc.props as component_props, vc.imports
|
||||
FROM canvas_components cc
|
||||
JOIN vue_components vc ON cc.component_id = vc.id
|
||||
WHERE cc.canvas_id = ?
|
||||
ORDER BY cc.position ASC
|
||||
`).all(canvasId) as any[]
|
||||
|
||||
const components = rows.map(row => ({
|
||||
id: row.id,
|
||||
canvasId: row.canvas_id,
|
||||
componentId: row.component_id,
|
||||
position: row.position,
|
||||
props: row.props ? JSON.parse(row.props) : {},
|
||||
layout: row.layout ? JSON.parse(row.layout) : null,
|
||||
isVisible: !!row.is_visible,
|
||||
createdAt: row.created_at,
|
||||
component: {
|
||||
id: row.component_id,
|
||||
name: row.name,
|
||||
template: row.template,
|
||||
setup: row.setup,
|
||||
style: row.style,
|
||||
props: row.component_props ? JSON.parse(row.component_props) : [],
|
||||
imports: row.imports ? JSON.parse(row.imports) : []
|
||||
}
|
||||
}))
|
||||
|
||||
return jsonResponse(components)
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json()
|
||||
|
||||
// Verify component exists
|
||||
const component = db.query('SELECT id FROM vue_components WHERE id = ?').get(body.component_id)
|
||||
if (!component) {
|
||||
return errorResponse('Component not found', 404)
|
||||
}
|
||||
|
||||
// Get next position
|
||||
const maxPos = db.query('SELECT MAX(position) as max FROM canvas_components WHERE canvas_id = ?').get(canvasId) as { max: number | null }
|
||||
const position = body.position ?? ((maxPos?.max ?? -1) + 1)
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO canvas_components
|
||||
(canvas_id, component_id, position, props, layout, is_visible)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
stmt.run(
|
||||
canvasId,
|
||||
body.component_id,
|
||||
position,
|
||||
JSON.stringify(body.props || {}),
|
||||
body.layout ? JSON.stringify(body.layout) : null,
|
||||
body.is_visible !== false ? 1 : 0
|
||||
)
|
||||
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function handleCanvasComponentById(req: Request, canvasId: string, componentId: string) {
|
||||
if (req.method === 'PUT') {
|
||||
const body = await req.json()
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
|
||||
if (body.position !== undefined) { updates.push('position = ?'); values.push(body.position) }
|
||||
if (body.props !== undefined) { updates.push('props = ?'); values.push(JSON.stringify(body.props)) }
|
||||
if (body.layout !== undefined) { updates.push('layout = ?'); values.push(JSON.stringify(body.layout)) }
|
||||
if (body.is_visible !== undefined) { updates.push('is_visible = ?'); values.push(body.is_visible ? 1 : 0) }
|
||||
|
||||
if (updates.length > 0) {
|
||||
values.push(canvasId, componentId)
|
||||
const sql = `UPDATE canvas_components SET ${updates.join(', ')} WHERE canvas_id = ? AND component_id = ?`
|
||||
db.run(sql, values)
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
db.run('DELETE FROM canvas_components WHERE canvas_id = ? AND component_id = ?', [canvasId, componentId])
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
84
server/routes/components.ts
Normal file
84
server/routes/components.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { db } from '../db'
|
||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||
|
||||
export async function handleComponents(req: Request) {
|
||||
if (req.method === 'GET') {
|
||||
const rows = db.query('SELECT * FROM vue_components ORDER BY updated_at DESC').all()
|
||||
return jsonResponse(rows)
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json()
|
||||
const id = body.id || `comp-${Date.now()}`
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO vue_components
|
||||
(id, name, template, setup, style, props, imports, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`)
|
||||
stmt.run(
|
||||
id,
|
||||
body.name,
|
||||
body.template,
|
||||
body.setup || '',
|
||||
body.style || '',
|
||||
JSON.stringify(body.props || []),
|
||||
JSON.stringify(body.imports || [])
|
||||
)
|
||||
return jsonResponse({ success: true, id })
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
db.run('DELETE FROM vue_components')
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function handleComponentById(req: Request, id: string) {
|
||||
if (req.method === 'GET') {
|
||||
const row = db.query('SELECT * FROM vue_components WHERE id = ?').get(id)
|
||||
if (!row) {
|
||||
return errorResponse('Component not found', 404)
|
||||
}
|
||||
return jsonResponse(row)
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
// Check if component is in use by any canvas
|
||||
const usage = db.query(`
|
||||
SELECT pc.id, pc.name
|
||||
FROM canvas_components cc
|
||||
JOIN project_canvas pc ON cc.canvas_id = pc.id
|
||||
WHERE cc.component_id = ?
|
||||
`).all(id) as { id: string; name: string }[]
|
||||
|
||||
if (usage.length > 0) {
|
||||
return jsonResponse({
|
||||
error: 'Component in use',
|
||||
message: `Cannot delete component. It is used by: ${usage.map(u => u.name).join(', ')}`,
|
||||
usedBy: usage
|
||||
}, 409)
|
||||
}
|
||||
|
||||
db.run('DELETE FROM vue_components WHERE id = ?', [id])
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function handleComponentUsage(componentId: string) {
|
||||
const usage = db.query(`
|
||||
SELECT pc.id, pc.name, pc.type
|
||||
FROM canvas_components cc
|
||||
JOIN project_canvas pc ON cc.canvas_id = pc.id
|
||||
WHERE cc.component_id = ?
|
||||
`).all(componentId) as { id: string; name: string; type: string }[]
|
||||
|
||||
return jsonResponse({
|
||||
componentId,
|
||||
usedBy: usage,
|
||||
canDelete: usage.length === 0
|
||||
})
|
||||
}
|
||||
27
server/routes/config.ts
Normal file
27
server/routes/config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { db } from '../db'
|
||||
import { jsonResponse } from '../utils/cors'
|
||||
|
||||
export async function handleConfig(req: Request, url: URL) {
|
||||
if (req.method === 'GET') {
|
||||
const key = url.searchParams.get('key')
|
||||
if (key) {
|
||||
const row = db.query('SELECT value FROM config WHERE key = ?').get(key) as { value: string } | null
|
||||
return jsonResponse({ value: row?.value || null })
|
||||
}
|
||||
const rows = db.query('SELECT * FROM config').all()
|
||||
return jsonResponse(rows)
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json()
|
||||
const stmt = db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
|
||||
stmt.run(body.key, body.value)
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function handleHealth() {
|
||||
return jsonResponse({ status: 'ok', timestamp: new Date().toISOString() })
|
||||
}
|
||||
119
server/routes/database.ts
Normal file
119
server/routes/database.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { db } from '../db'
|
||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||
|
||||
export function handleTables() {
|
||||
const tables = db.query(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`).all() as { name: string }[]
|
||||
|
||||
const result = tables.map(t => {
|
||||
const countResult = db.query(`SELECT COUNT(*) as count FROM "${t.name}"`).get() as { count: number }
|
||||
return { name: t.name, count: countResult.count }
|
||||
})
|
||||
|
||||
return jsonResponse(result)
|
||||
}
|
||||
|
||||
export async function handleStats() {
|
||||
// Get database file size
|
||||
const file = Bun.file('agent-ui.db')
|
||||
const size = file.size
|
||||
const sizeStr = size < 1024 ? `${size} B`
|
||||
: size < 1024 * 1024 ? `${(size / 1024).toFixed(1)} KB`
|
||||
: `${(size / (1024 * 1024)).toFixed(2)} MB`
|
||||
|
||||
// Get tables and counts
|
||||
const tables = db.query(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
`).all() as { name: string }[]
|
||||
|
||||
let totalRecords = 0
|
||||
const breakdown = tables.map(t => {
|
||||
const countResult = db.query(`SELECT COUNT(*) as count FROM "${t.name}"`).get() as { count: number }
|
||||
totalRecords += countResult.count
|
||||
return { name: t.name, count: countResult.count }
|
||||
})
|
||||
|
||||
return jsonResponse({
|
||||
size: sizeStr,
|
||||
tables: tables.length,
|
||||
totalRecords,
|
||||
breakdown
|
||||
})
|
||||
}
|
||||
|
||||
export function handleTableSchema(tableName: string) {
|
||||
// Verify table exists
|
||||
const tableExists = db.query(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name = ?
|
||||
`).get(tableName)
|
||||
|
||||
if (!tableExists) {
|
||||
return errorResponse('Table not found', 404)
|
||||
}
|
||||
|
||||
const schema = db.query(`PRAGMA table_info("${tableName}")`).all() as any[]
|
||||
const result = schema.map(col => ({
|
||||
name: col.name,
|
||||
type: col.type,
|
||||
notnull: !!col.notnull,
|
||||
pk: !!col.pk
|
||||
}))
|
||||
|
||||
return jsonResponse(result)
|
||||
}
|
||||
|
||||
export function handleTableData(tableName: string, url: URL) {
|
||||
// Verify table exists
|
||||
const tableExists = db.query(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name = ?
|
||||
`).get(tableName)
|
||||
|
||||
if (!tableExists) {
|
||||
return errorResponse('Table not found', 404)
|
||||
}
|
||||
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 500)
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0')
|
||||
|
||||
const countResult = db.query(`SELECT COUNT(*) as count FROM "${tableName}"`).get() as { count: number }
|
||||
const rows = db.query(`SELECT * FROM "${tableName}" LIMIT ? OFFSET ?`).all(limit, offset)
|
||||
|
||||
return jsonResponse({
|
||||
total: countResult.count,
|
||||
limit,
|
||||
offset,
|
||||
rows
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleQuery(req: Request) {
|
||||
const body = await req.json()
|
||||
const query = (body.query || '').trim()
|
||||
|
||||
// Security: Only allow SELECT statements
|
||||
const normalizedQuery = query.toLowerCase()
|
||||
if (!normalizedQuery.startsWith('select')) {
|
||||
return errorResponse('Only SELECT queries are allowed for security reasons', 403)
|
||||
}
|
||||
|
||||
// Block dangerous keywords
|
||||
const dangerousKeywords = ['drop', 'delete', 'update', 'insert', 'alter', 'create', 'truncate', 'replace']
|
||||
for (const keyword of dangerousKeywords) {
|
||||
if (normalizedQuery.includes(keyword)) {
|
||||
return errorResponse(`Query contains forbidden keyword: ${keyword.toUpperCase()}`, 403)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = db.query(query).all()
|
||||
return jsonResponse({ rows })
|
||||
} catch (e: any) {
|
||||
return errorResponse(e.message, 400)
|
||||
}
|
||||
}
|
||||
130
server/routes/gitea.ts
Normal file
130
server/routes/gitea.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||
|
||||
export async function handleGiteaRepo(req: Request) {
|
||||
const body = await req.json()
|
||||
const { giteaUrl, username, password, owner, repo } = body
|
||||
|
||||
if (!giteaUrl || !username || !password || !owner || !repo) {
|
||||
return errorResponse('Missing required fields', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = Buffer.from(`${username}:${password}`).toString('base64')
|
||||
|
||||
// Get repo info
|
||||
const repoRes = await fetch(`${giteaUrl}/api/v1/repos/${owner}/${repo}`, {
|
||||
headers: { 'Authorization': `Basic ${auth}` }
|
||||
})
|
||||
|
||||
if (!repoRes.ok) {
|
||||
if (repoRes.status === 401) {
|
||||
return errorResponse('Invalid credentials', 401)
|
||||
}
|
||||
if (repoRes.status === 404) {
|
||||
return errorResponse('Repository not found', 404)
|
||||
}
|
||||
throw new Error('Failed to connect to Gitea')
|
||||
}
|
||||
|
||||
const repoData = await repoRes.json()
|
||||
|
||||
// Get branches
|
||||
const branchesRes = await fetch(`${giteaUrl}/api/v1/repos/${owner}/${repo}/branches`, {
|
||||
headers: { 'Authorization': `Basic ${auth}` }
|
||||
})
|
||||
|
||||
let branches = ['main']
|
||||
if (branchesRes.ok) {
|
||||
const branchesData = await branchesRes.json()
|
||||
branches = branchesData.map((b: any) => b.name)
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
repo: {
|
||||
name: repoData.name,
|
||||
description: repoData.description,
|
||||
default_branch: repoData.default_branch,
|
||||
stars_count: repoData.stars_count,
|
||||
forks_count: repoData.forks_count,
|
||||
owner: { login: repoData.owner?.login || owner }
|
||||
},
|
||||
branches
|
||||
})
|
||||
} catch (e: any) {
|
||||
return errorResponse(e.message, 500)
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGiteaTree(req: Request) {
|
||||
const body = await req.json()
|
||||
const { giteaUrl, username, password, owner, repo, branch, path } = body
|
||||
|
||||
try {
|
||||
const auth = Buffer.from(`${username}:${password}`).toString('base64')
|
||||
const apiPath = path
|
||||
? `${giteaUrl}/api/v1/repos/${owner}/${repo}/contents/${path}?ref=${branch}`
|
||||
: `${giteaUrl}/api/v1/repos/${owner}/${repo}/contents?ref=${branch}`
|
||||
|
||||
const res = await fetch(apiPath, {
|
||||
headers: { 'Authorization': `Basic ${auth}` }
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to load tree')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const items = Array.isArray(data) ? data : [data]
|
||||
|
||||
const tree = items
|
||||
.map((item: any) => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: item.type === 'dir' ? 'dir' : 'file',
|
||||
children: item.type === 'dir' ? [] : undefined
|
||||
}))
|
||||
.sort((a: any, b: any) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return jsonResponse({ tree })
|
||||
} catch (e: any) {
|
||||
return errorResponse(e.message, 500)
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGiteaFile(req: Request) {
|
||||
const body = await req.json()
|
||||
const { giteaUrl, username, password, owner, repo, branch, path } = body
|
||||
|
||||
try {
|
||||
const auth = Buffer.from(`${username}:${password}`).toString('base64')
|
||||
|
||||
const res = await fetch(
|
||||
`${giteaUrl}/api/v1/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
|
||||
{ headers: { 'Authorization': `Basic ${auth}` } }
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to load file')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
// Decode base64 content
|
||||
let content = ''
|
||||
if (data.content) {
|
||||
content = Buffer.from(data.content, 'base64').toString('utf-8')
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
content,
|
||||
encoding: data.encoding,
|
||||
size: data.size,
|
||||
sha: data.sha
|
||||
})
|
||||
} catch (e: any) {
|
||||
return errorResponse(e.message, 500)
|
||||
}
|
||||
}
|
||||
24
server/routes/history.ts
Normal file
24
server/routes/history.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { db } from '../db'
|
||||
import { jsonResponse } from '../utils/cors'
|
||||
|
||||
export async function handleHistory(req: Request, url: URL) {
|
||||
if (req.method === 'GET') {
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50')
|
||||
const rows = db.query('SELECT * FROM history ORDER BY id DESC LIMIT ?').all(limit)
|
||||
return jsonResponse(rows)
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json()
|
||||
const stmt = db.prepare('INSERT INTO history (tool_name, args, result) VALUES (?, ?, ?)')
|
||||
stmt.run(body.tool_name, JSON.stringify(body.args), body.result)
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
db.run('DELETE FROM history')
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
172
server/routes/index.ts
Normal file
172
server/routes/index.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { optionsResponse, notFoundResponse } from '../utils/cors'
|
||||
import { handleHistory } from './history'
|
||||
import { handleConfig, handleHealth } from './config'
|
||||
import { handleWebMCPToken } from './webmcp'
|
||||
import { handleComponents, handleComponentById, handleComponentUsage } from './components'
|
||||
import { handleThemes, handleActiveTheme, handleDesignTokens, handleThemeById, handleThemeExport } from './themes'
|
||||
import { handleCanvas, handleCanvasById, handleToolbarCanvas, handleDefaultCanvas, handleCanvasComponents, handleCanvasComponentById } from './canvas'
|
||||
import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea'
|
||||
import { handleTables, handleStats, handleTableSchema, handleTableData, handleQuery } from './database'
|
||||
|
||||
export async function handleRequest(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url)
|
||||
const path = url.pathname
|
||||
|
||||
// CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return optionsResponse()
|
||||
}
|
||||
|
||||
// Health
|
||||
if (path === '/api/health') {
|
||||
return handleHealth()
|
||||
}
|
||||
|
||||
// History
|
||||
if (path === '/api/history') {
|
||||
const res = await handleHistory(req, url)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Config
|
||||
if (path === '/api/config') {
|
||||
const res = await handleConfig(req, url)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// WebMCP Token
|
||||
if (path === '/api/webmcp-token') {
|
||||
const res = await handleWebMCPToken(req)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Components
|
||||
if (path === '/api/components') {
|
||||
const res = await handleComponents(req)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Component usage
|
||||
const componentUsageMatch = path.match(/^\/api\/components\/([^/]+)\/usage$/)
|
||||
if (componentUsageMatch && req.method === 'GET') {
|
||||
return handleComponentUsage(componentUsageMatch[1])
|
||||
}
|
||||
|
||||
// Component by ID
|
||||
if (path.startsWith('/api/components/') && !path.includes('/usage')) {
|
||||
const id = path.split('/').pop()!
|
||||
const res = await handleComponentById(req, id)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Themes
|
||||
if (path === '/api/themes') {
|
||||
const res = await handleThemes(req)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
if (path === '/api/themes/active') {
|
||||
return handleActiveTheme()
|
||||
}
|
||||
|
||||
if (path === '/api/design-tokens') {
|
||||
return handleDesignTokens()
|
||||
}
|
||||
|
||||
// Theme export
|
||||
if (path.startsWith('/api/themes/export/')) {
|
||||
const id = path.split('/').pop()!
|
||||
if (req.method === 'GET') {
|
||||
return handleThemeExport(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Theme by ID
|
||||
if (path.startsWith('/api/themes/') && !path.includes('/active') && !path.includes('/export')) {
|
||||
const pathParts = path.split('/')
|
||||
const id = pathParts[3]
|
||||
const action = pathParts[4]
|
||||
const res = await handleThemeById(req, id, action)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Canvas toolbar
|
||||
if (path === '/api/canvas/toolbar') {
|
||||
return handleToolbarCanvas()
|
||||
}
|
||||
|
||||
// Canvas default
|
||||
if (path === '/api/canvas/default') {
|
||||
return handleDefaultCanvas()
|
||||
}
|
||||
|
||||
// Canvas list/create
|
||||
if (path === '/api/canvas') {
|
||||
const res = await handleCanvas(req)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Canvas components
|
||||
const canvasComponentsMatch = path.match(/^\/api\/canvas\/([^/]+)\/components\/?$/)
|
||||
if (canvasComponentsMatch) {
|
||||
const res = await handleCanvasComponents(req, canvasComponentsMatch[1])
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Canvas component by ID
|
||||
const canvasComponentMatch = path.match(/^\/api\/canvas\/([^/]+)\/components\/([^/]+)$/)
|
||||
if (canvasComponentMatch) {
|
||||
const res = await handleCanvasComponentById(req, canvasComponentMatch[1], canvasComponentMatch[2])
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Canvas by ID
|
||||
if (path.startsWith('/api/canvas/') && !path.includes('/components')) {
|
||||
const pathParts = path.split('/')
|
||||
const id = pathParts[3]
|
||||
const action = pathParts[4]
|
||||
const res = await handleCanvasById(req, id, action)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Gitea
|
||||
if (path === '/api/gitea/repo' && req.method === 'POST') {
|
||||
return handleGiteaRepo(req)
|
||||
}
|
||||
|
||||
if (path === '/api/gitea/tree' && req.method === 'POST') {
|
||||
return handleGiteaTree(req)
|
||||
}
|
||||
|
||||
if (path === '/api/gitea/file' && req.method === 'POST') {
|
||||
return handleGiteaFile(req)
|
||||
}
|
||||
|
||||
// Database Explorer
|
||||
if (path === '/api/database/tables') {
|
||||
return handleTables()
|
||||
}
|
||||
|
||||
if (path === '/api/database/stats') {
|
||||
return handleStats()
|
||||
}
|
||||
|
||||
// Table schema
|
||||
const tableSchemaMatch = path.match(/^\/api\/database\/tables\/([^/]+)\/schema$/)
|
||||
if (tableSchemaMatch && req.method === 'GET') {
|
||||
return handleTableSchema(decodeURIComponent(tableSchemaMatch[1]))
|
||||
}
|
||||
|
||||
// Table data
|
||||
const tableDataMatch = path.match(/^\/api\/database\/tables\/([^/]+)\/data$/)
|
||||
if (tableDataMatch && req.method === 'GET') {
|
||||
return handleTableData(decodeURIComponent(tableDataMatch[1]), url)
|
||||
}
|
||||
|
||||
// Database query
|
||||
if (path === '/api/database/query' && req.method === 'POST') {
|
||||
return handleQuery(req)
|
||||
}
|
||||
|
||||
return notFoundResponse()
|
||||
}
|
||||
157
server/routes/themes.ts
Normal file
157
server/routes/themes.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { db } from '../db'
|
||||
import { jsonResponse, errorResponse, corsHeaders } from '../utils/cors'
|
||||
|
||||
function parseTheme(row: any) {
|
||||
return {
|
||||
...row,
|
||||
is_default: !!row.is_default,
|
||||
is_system: !!row.is_system,
|
||||
variables: JSON.parse(row.variables),
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : null
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleThemes(req: Request) {
|
||||
if (req.method === 'GET') {
|
||||
const rows = db.query('SELECT * FROM themes ORDER BY is_system DESC, is_default DESC, name ASC').all()
|
||||
const themes = (rows as any[]).map(parseTheme)
|
||||
return jsonResponse(themes)
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json()
|
||||
const id = body.id || `theme-${Date.now()}`
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO themes
|
||||
(id, name, description, is_default, is_system, variables, metadata, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`)
|
||||
stmt.run(
|
||||
id,
|
||||
body.name,
|
||||
body.description || '',
|
||||
body.is_default ? 1 : 0,
|
||||
body.is_system ? 1 : 0,
|
||||
JSON.stringify(body.variables),
|
||||
JSON.stringify(body.metadata || {})
|
||||
)
|
||||
return jsonResponse({ success: true, id })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function handleActiveTheme() {
|
||||
const row = db.query('SELECT * FROM themes WHERE is_default = 1 LIMIT 1').get() as any
|
||||
if (!row) {
|
||||
return errorResponse('No active theme', 404)
|
||||
}
|
||||
return jsonResponse(parseTheme(row))
|
||||
}
|
||||
|
||||
export function handleDesignTokens() {
|
||||
const row = db.query('SELECT variables FROM themes WHERE is_default = 1 LIMIT 1').get() as { variables: string } | null
|
||||
const tokens = row ? JSON.parse(row.variables) : {}
|
||||
|
||||
return jsonResponse({
|
||||
version: '1.0.0',
|
||||
description: 'Design tokens for Agent UI components. Use these CSS variables for consistent styling.',
|
||||
usage: 'Use var(--token-name) in CSS, e.g., var(--bg-primary)',
|
||||
tokens,
|
||||
guidelines: {
|
||||
backgrounds: 'Use bg-primary for main areas, bg-secondary for cards/panels, bg-tertiary for nested elements',
|
||||
text: 'Use text-primary for headings, text-secondary for body, text-muted for hints',
|
||||
accent: 'Use accent for interactive elements, accent-hover for hover states, accent-muted for backgrounds',
|
||||
semantic: 'Use success/warning/error/info for status indicators with their -bg variants for backgrounds',
|
||||
spacing: 'Use radius-sm (4px) for small elements, radius-md (8px) for cards, radius-lg (12px) for modals',
|
||||
effects: 'Use transition-fast for micro-interactions, shadow-md for elevated elements'
|
||||
},
|
||||
examples: {
|
||||
button: 'background: var(--accent); color: var(--accent-text); border-radius: var(--radius-md); transition: var(--transition-fast);',
|
||||
card: 'background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm);',
|
||||
input: 'background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: var(--radius-md);',
|
||||
badge: 'background: var(--accent-muted); color: var(--accent); padding: 0.25rem 0.5rem; border-radius: var(--radius-full);'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleThemeById(req: Request, id: string, action?: string) {
|
||||
// POST /api/themes/:id/default - Set as default
|
||||
if (action === 'default' && req.method === 'POST') {
|
||||
db.run('UPDATE themes SET is_default = 0')
|
||||
db.run('UPDATE themes SET is_default = 1 WHERE id = ?', [id])
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
// PUT /api/themes/:id - Update theme
|
||||
if (req.method === 'PUT' && !action) {
|
||||
const theme = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any
|
||||
if (!theme) {
|
||||
return errorResponse('Theme not found', 404)
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
|
||||
if (body.name !== undefined) { updates.push('name = ?'); values.push(body.name) }
|
||||
if (body.description !== undefined) { updates.push('description = ?'); values.push(body.description) }
|
||||
if (body.variables !== undefined) { updates.push('variables = ?'); values.push(JSON.stringify(body.variables)) }
|
||||
if (body.metadata !== undefined) { updates.push('metadata = ?'); values.push(JSON.stringify(body.metadata)) }
|
||||
|
||||
if (updates.length > 0) {
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP')
|
||||
values.push(id)
|
||||
const sql = `UPDATE themes SET ${updates.join(', ')} WHERE id = ?`
|
||||
db.run(sql, values)
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, id })
|
||||
}
|
||||
|
||||
// GET /api/themes/:id - Get theme
|
||||
if (req.method === 'GET' && !action) {
|
||||
const row = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any
|
||||
if (!row) {
|
||||
return errorResponse('Theme not found', 404)
|
||||
}
|
||||
return jsonResponse(parseTheme(row))
|
||||
}
|
||||
|
||||
// DELETE /api/themes/:id - Delete theme
|
||||
if (req.method === 'DELETE' && !action) {
|
||||
const theme = db.query('SELECT is_system FROM themes WHERE id = ?').get(id) as { is_system: number } | null
|
||||
if (theme?.is_system) {
|
||||
return errorResponse('Cannot delete system theme', 403)
|
||||
}
|
||||
db.run('DELETE FROM themes WHERE id = ?', [id])
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function handleThemeExport(id: string) {
|
||||
const row = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any
|
||||
if (!row) {
|
||||
return errorResponse('Theme not found', 404)
|
||||
}
|
||||
|
||||
const exportData = {
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
variables: JSON.parse(row.variables),
|
||||
metadata: {
|
||||
...(row.metadata ? JSON.parse(row.metadata) : {}),
|
||||
exported_at: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(exportData, null, 2), {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="${row.name.toLowerCase().replace(/\s+/g, '-')}-theme.json"`
|
||||
}
|
||||
})
|
||||
}
|
||||
42
server/routes/webmcp.ts
Normal file
42
server/routes/webmcp.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||
|
||||
// WebMCP token storage (in-memory)
|
||||
let pendingWebMCPToken: { token: string; createdAt: Date } | null = null
|
||||
|
||||
export async function handleWebMCPToken(req: Request) {
|
||||
if (req.method === 'GET') {
|
||||
if (pendingWebMCPToken) {
|
||||
// Check if token is not expired (5 minutes)
|
||||
const age = Date.now() - pendingWebMCPToken.createdAt.getTime()
|
||||
if (age < 5 * 60 * 1000) {
|
||||
return jsonResponse({
|
||||
token: pendingWebMCPToken.token,
|
||||
createdAt: pendingWebMCPToken.createdAt.toISOString()
|
||||
})
|
||||
}
|
||||
// Token expired
|
||||
pendingWebMCPToken = null
|
||||
}
|
||||
return jsonResponse({ token: null })
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json()
|
||||
if (body.token) {
|
||||
pendingWebMCPToken = {
|
||||
token: body.token,
|
||||
createdAt: new Date()
|
||||
}
|
||||
console.log('[WebMCP] Token received and stored')
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
return errorResponse('Token required', 400)
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
pendingWebMCPToken = null
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
203
server/services/terminal.ts
Normal file
203
server/services/terminal.ts
Normal 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
21
server/utils/cors.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
}
|
||||
|
||||
export function optionsResponse() {
|
||||
return new Response(null, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
export function jsonResponse(data: unknown, status = 200) {
|
||||
return Response.json(data, { status, headers: corsHeaders })
|
||||
}
|
||||
|
||||
export function errorResponse(error: string, status = 400) {
|
||||
return Response.json({ error }, { status, headers: corsHeaders })
|
||||
}
|
||||
|
||||
export function notFoundResponse() {
|
||||
return new Response('Not Found', { status: 404, headers: corsHeaders })
|
||||
}
|
||||
Reference in New Issue
Block a user