feat: Add /tools page with centralized tool registry management
- Add ToolsPage for managing MCP tools activation and persistence - Centralize all tool handlers in services/tools/handlers/ - toolRegistry.ts is now the single source of truth for tool state - Add tools store for pinned tools (persisted in localStorage) - Tools can be pinned to stay active across page navigation - Remove old individual tool files, replaced by centralized handlers
This commit is contained in:
@@ -14,7 +14,7 @@ const router = useRouter()
|
||||
const showTerminal = ref(false)
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
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,128 @@
|
||||
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,
|
||||
type ToolConfig
|
||||
} from './tools/handlers'
|
||||
import { setRouter } from './tools/handlers/globalHandlers'
|
||||
import { setGiteaCredentials, clearGiteaCredentials } from './tools/handlers/sourceCodeHandlers'
|
||||
import { ALL_TOOL_METAS, 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 all handlers
|
||||
const allHandlers = [
|
||||
...createGlobalHandlers(() => Array.from(registeredToolsSet)),
|
||||
...createCanvasHandlers(),
|
||||
...createComponentHandlers(),
|
||||
...createThemeHandlers(),
|
||||
...createDatabaseHandlers(),
|
||||
...createProjectCanvasHandlers(),
|
||||
...createSourceCodeHandlers()
|
||||
]
|
||||
|
||||
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'],
|
||||
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']
|
||||
}
|
||||
|
||||
// Page to categories mapping
|
||||
const pageCategories: Record<PageName, ToolCategory[]> = {
|
||||
home: ['global', 'canvas', 'component', 'project'],
|
||||
canvas: ['global', 'canvas', 'component'],
|
||||
'project-canvas': ['global', 'canvas', 'component', 'project'],
|
||||
projects: ['global', 'project'],
|
||||
components: ['global', 'component'],
|
||||
themes: ['global', 'theme'],
|
||||
database: ['global', 'database'],
|
||||
source: ['global', 'source'],
|
||||
terminal: ['global'],
|
||||
tools: ['global']
|
||||
}
|
||||
|
||||
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 +130,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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
108
frontend/src/services/tools/handlers/globalHandlers.ts
Normal file
108
frontend/src/services/tools/handlers/globalHandlers.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { ToolConfig } from './index'
|
||||
|
||||
let routerInstance: any = null
|
||||
|
||||
export function setRouter(router: any) {
|
||||
routerInstance = router
|
||||
}
|
||||
|
||||
export function createGlobalHandlers(getRegisteredTools: () => string[]): ToolConfig[] {
|
||||
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')}`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
22
frontend/src/services/tools/handlers/index.ts
Normal file
22
frontend/src/services/tools/handlers/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 type ToolHandler = (args: any) => string | Promise<string>
|
||||
|
||||
export interface ToolConfig {
|
||||
name: string
|
||||
description: string
|
||||
category: 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project'
|
||||
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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
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)
|
||||
}
|
||||
84
frontend/src/services/tools/toolDefinitions.ts
Normal file
84
frontend/src/services/tools/toolDefinitions.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export type ToolCategory = 'global' | 'canvas' | 'component' | 'theme' | 'database' | 'source' | 'project'
|
||||
|
||||
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' },
|
||||
|
||||
// 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' }
|
||||
]
|
||||
|
||||
// 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' }
|
||||
}
|
||||
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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user