feat: Auto-save components, soft delete, tags, compact WCO header
- Auto-save rendered Vue components to DB on render_vue_component - Soft delete (archive) instead of hard delete for components - Tags support for component categorization - Gallery limited to 10 most recent items per section - Upsert with ON CONFLICT for component saves - PUT endpoint for partial component updates - Collapsible toolbar with animated toggle button - Window Controls Overlay support for PWA titlebar - Compact header mode (32px) with hidden dot toggle - Dynamic theme-color meta sync for Windows titlebar
This commit is contained in:
@@ -6,6 +6,7 @@ import TorchButton from './components/TorchButton.vue'
|
||||
import FloatingTerminal from './components/FloatingTerminal.vue'
|
||||
import FloatingResponse from './components/FloatingResponse.vue'
|
||||
import FloatingVoice from './components/FloatingVoice.vue'
|
||||
import AgentBar from './components/AgentBar.vue'
|
||||
import PwaInstallBanner from './components/PwaInstallBanner.vue'
|
||||
import { initWebMCP, getWebMCP } from './services/webmcp'
|
||||
import { initTorch, destroyTorch } from './services/torch'
|
||||
@@ -21,6 +22,8 @@ const router = useRouter()
|
||||
const showTerminal = ref(false)
|
||||
const showVoice = ref(false)
|
||||
const showDebugConsole = ref(false)
|
||||
const toolbarVisible = ref(true)
|
||||
const forceWco = ref(false)
|
||||
const debugLogs = ref<Array<{ type: string; message: string; time: string }>>([])
|
||||
|
||||
// Intercept console.log for debug panel
|
||||
@@ -242,7 +245,17 @@ function triggerToolFlash() {
|
||||
|
||||
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools' | 'agents'
|
||||
|
||||
function syncThemeColor() {
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-primary').trim()
|
||||
if (bg) {
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Sync Windows titlebar color with CSS variable
|
||||
syncThemeColor()
|
||||
|
||||
// Connect to WebSocket for Claude status updates
|
||||
connectStatusWs()
|
||||
|
||||
@@ -348,10 +361,20 @@ watch(() => route.name, (newPage) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<div class="app-container" :class="{ wco: forceWco }">
|
||||
<header class="app-header" :class="{ 'wco-header': forceWco }">
|
||||
<div class="header-left">
|
||||
<h1 class="logo">Agent UI</h1>
|
||||
<button
|
||||
class="toolbar-toggle"
|
||||
:class="{ collapsed: !toolbarVisible }"
|
||||
@click="toolbarVisible = !toolbarVisible"
|
||||
:title="toolbarVisible ? 'Ocultar toolbar' : 'Mostrar toolbar'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<template v-if="projectCanvasStore.activeCanvas && route.name === 'project-canvas'">
|
||||
<span class="header-sep">/</span>
|
||||
<span class="header-canvas-name">{{ projectCanvasStore.activeCanvas.name }}</span>
|
||||
@@ -376,11 +399,12 @@ watch(() => route.name, (newPage) => {
|
||||
<path d="M21 3v5h-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="wco-dot" :class="{ on: forceWco }" @click="forceWco = !forceWco"></span>
|
||||
<TorchButton />
|
||||
</div>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<Toolbar />
|
||||
<Toolbar :collapsed="!toolbarVisible" />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
@@ -490,6 +514,9 @@ watch(() => route.name, (newPage) => {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Agent Bar (bottom pills) -->
|
||||
<AgentBar />
|
||||
|
||||
<!-- Floating Terminal -->
|
||||
<FloatingTerminal ref="terminalRef" v-model="showTerminal" />
|
||||
|
||||
@@ -544,51 +571,200 @@ watch(() => route.name, (newPage) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
padding-top: calc(0.75rem + env(safe-area-inset-top, 0px));
|
||||
padding-left: calc(1.5rem + env(safe-area-inset-left, 0px));
|
||||
padding-right: calc(1.5rem + env(safe-area-inset-right, 0px));
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.5rem 1rem;
|
||||
padding-top: calc(0.5rem + env(safe-area-inset-top, 0px));
|
||||
padding-left: calc(1rem + env(safe-area-inset-left, 0px));
|
||||
padding-right: calc(1rem + env(safe-area-inset-right, 0px));
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
-webkit-app-region: drag;
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
gap: 0.75rem;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
/* ── Compact header (WCO + manual toggle) ── */
|
||||
.wco-header,
|
||||
.wco-header.app-header {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
padding: 0 0.5rem;
|
||||
border-bottom: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.wco-header .header-left { gap: 0.35rem; overflow: visible; }
|
||||
.wco-header .header-right { gap: 0.2rem; }
|
||||
|
||||
.wco-header .toolbar-toggle {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wco-header .toolbar-toggle svg { width: 12px; height: 12px; }
|
||||
|
||||
.wco-header .header-sep { font-size: 0.65rem; }
|
||||
.wco-header .header-canvas-name { font-size: 0.65rem; max-width: 100px; }
|
||||
.wco-header .header-canvas-badge { font-size: 0.5rem; padding: 0 0.2rem; }
|
||||
.wco-header :deep(.pwa-banner) { display: none; }
|
||||
|
||||
.wco-header .debug-btn {
|
||||
padding: 1px 4px;
|
||||
font-size: 8px;
|
||||
border-radius: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
.wco-header .debug-btn svg { width: 10px; height: 10px; }
|
||||
.wco-header .log-count { font-size: 7px; padding: 0 2px; min-width: 10px; }
|
||||
|
||||
.wco-header .refresh-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wco-header .refresh-btn svg { width: 12px; height: 12px; }
|
||||
|
||||
/* TorchButton compact via :deep */
|
||||
.wco-header :deep(.trigger-split) { border-radius: 4px; font-size: 0.65rem; }
|
||||
.wco-header :deep(.trigger-main) { padding: 0.1rem 0.25rem 0.1rem 0.35rem; gap: 0.25rem; }
|
||||
.wco-header :deep(.trigger-chevron) { padding: 0.1rem 0.25rem; }
|
||||
.wco-header :deep(.status-dot) { width: 5px; height: 5px; }
|
||||
.wco-header :deep(.chevron) { width: 10px; height: 10px; }
|
||||
.wco-header :deep(.trigger-name) { max-width: 60px; font-size: 0.65rem; }
|
||||
|
||||
/* Window Controls Overlay — real PWA titlebar */
|
||||
@media (display-mode: window-controls-overlay) {
|
||||
.app-header {
|
||||
position: fixed;
|
||||
top: env(titlebar-area-y, 0);
|
||||
left: env(titlebar-area-x, 0);
|
||||
width: env(titlebar-area-width, 100%);
|
||||
height: env(titlebar-area-height, 32px);
|
||||
min-height: unset;
|
||||
max-height: env(titlebar-area-height, 32px);
|
||||
padding: 0 0.5rem;
|
||||
z-index: 10000;
|
||||
border-bottom: none;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.app-container {
|
||||
padding-top: env(titlebar-area-height, 32px);
|
||||
}
|
||||
|
||||
/* All the same compacting as .wco-header */
|
||||
.header-left { gap: 0.35rem; }
|
||||
.header-right { gap: 0.2rem; }
|
||||
.toolbar-toggle { width: 22px; height: 22px; border-radius: 4px; }
|
||||
.toolbar-toggle svg { width: 12px; height: 12px; }
|
||||
.header-sep { font-size: 0.65rem; }
|
||||
.header-canvas-name { font-size: 0.65rem; max-width: 100px; }
|
||||
.header-canvas-badge { font-size: 0.5rem; padding: 0 0.2rem; }
|
||||
.debug-btn { padding: 1px 4px; font-size: 8px; border-radius: 3px; gap: 2px; }
|
||||
.debug-btn svg { width: 10px; height: 10px; }
|
||||
.log-count { font-size: 7px; padding: 0 2px; min-width: 10px; }
|
||||
.refresh-btn { width: 22px; height: 22px; border-radius: 4px; }
|
||||
.refresh-btn svg { width: 12px; height: 12px; }
|
||||
}
|
||||
|
||||
/* Tiny hidden toggle dot */
|
||||
.wco-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-color);
|
||||
opacity: 0.18;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wco-dot:hover {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.8);
|
||||
}
|
||||
|
||||
.wco-dot.on {
|
||||
background: #6366f1;
|
||||
opacity: 0.6;
|
||||
box-shadow: 0 0 4px rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.toolbar-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 5px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent, #6366f1);
|
||||
border-color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
.toolbar-toggle svg {
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.toolbar-toggle.collapsed svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.toolbar-toggle.collapsed {
|
||||
color: var(--accent, #6366f1);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
.header-sep {
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.header-canvas-name {
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.header-canvas-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #6366f1;
|
||||
font-size: 0.6875rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
}
|
||||
@@ -597,12 +773,12 @@ watch(() => route.name, (newPage) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
border-radius: 5px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
@@ -1115,13 +1291,13 @@ watch(() => route.name, (newPage) => {
|
||||
.debug-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
gap: 3px;
|
||||
padding: 3px 6px;
|
||||
background: rgba(100, 100, 100, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
border-radius: 5px;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
@@ -1140,11 +1316,12 @@ watch(() => route.name, (newPage) => {
|
||||
.log-count {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
font-size: 8px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 10px;
|
||||
min-width: 16px;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Debug Console Panel */
|
||||
|
||||
@@ -38,13 +38,15 @@ const editIcon = ref('')
|
||||
const editOrder = ref(99)
|
||||
|
||||
const filteredCanvases = computed(() => {
|
||||
const list = showArchived.value ? store.canvases : store.activeCanvasesList
|
||||
if (!searchQuery.value) return list
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return list.filter(c =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
(c.description && c.description.toLowerCase().includes(q))
|
||||
)
|
||||
let list = showArchived.value ? store.canvases : store.activeCanvasesList
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
list = list.filter(c =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
(c.description && c.description.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
return list.slice(0, 10)
|
||||
})
|
||||
|
||||
const filteredSnapshots = computed(() => {
|
||||
@@ -168,7 +170,7 @@ async function loadComponent(comp: VueComponentDefinition) {
|
||||
|
||||
async function fetchComponents() {
|
||||
try {
|
||||
savedComponents.value = await componentsApi.getAll()
|
||||
savedComponents.value = await componentsApi.getAll({ limit: 10 })
|
||||
} catch {
|
||||
savedComponents.value = []
|
||||
}
|
||||
@@ -432,10 +434,14 @@ onMounted(() => {
|
||||
<div class="card-content">
|
||||
<div class="card-name">{{ comp.name }}</div>
|
||||
<div class="card-desc card-id">{{ comp.id }}</div>
|
||||
<div v-if="comp.tags?.length" class="card-tags">
|
||||
<span v-for="tag in comp.tags" :key="tag" class="tag-pill">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<span class="card-badge component">componente</span>
|
||||
<span v-if="comp.status === 'archived'" class="card-badge archived-badge">Archivado</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
@@ -955,4 +961,20 @@ onMounted(() => {
|
||||
.new-btn.cancel:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
padding: 0.0625rem 0.375rem;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: #818cf8;
|
||||
border-radius: 999px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,10 @@ import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
import { useProjectCanvasStore } from '../stores/projectCanvas'
|
||||
|
||||
defineProps<{
|
||||
collapsed?: boolean
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const canvasStore = useCanvasStore()
|
||||
const projectCanvasStore = useProjectCanvasStore()
|
||||
@@ -26,7 +30,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="toolbar">
|
||||
<aside class="toolbar" :class="{ collapsed }">
|
||||
<!-- Navegacion principal -->
|
||||
<div class="toolbar-section nav-section">
|
||||
<RouterLink to="/" class="toolbar-btn" :class="{ active: route.path === '/' }" title="Home">
|
||||
@@ -121,6 +125,12 @@ onMounted(() => {
|
||||
<path d="M18 9a9 9 0 0 1-9 9"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/agents" class="toolbar-btn" :class="{ active: route.path === '/agents' }" title="Agents">
|
||||
<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="M12 2L9.5 7.5 4 8.5l4 4-1 5.5L12 15l5 3-1-5.5 4-4-5.5-1z"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
@@ -154,6 +164,19 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
padding 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar.collapsed {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
border-right-color: transparent;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toolbar-section {
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface VueComponentDefinition {
|
||||
style?: string
|
||||
props?: string[]
|
||||
imports?: string[]
|
||||
tags?: string[]
|
||||
status?: 'active' | 'archived'
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -119,13 +123,18 @@ function injectScopedStyle(css: string, scopeId: string, componentId: string): v
|
||||
// ============================================
|
||||
|
||||
export const componentsApi = {
|
||||
async getAll(): Promise<VueComponentDefinition[]> {
|
||||
const res = await fetch(`${API_URL}/api/components`)
|
||||
async getAll(opts?: { includeArchived?: boolean; limit?: number }): Promise<VueComponentDefinition[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (opts?.includeArchived) params.set('include_archived', 'true')
|
||||
if (opts?.limit) params.set('limit', String(opts.limit))
|
||||
const qs = params.toString()
|
||||
const res = await fetch(`${API_URL}/api/components${qs ? '?' + qs : ''}`)
|
||||
const data = await res.json()
|
||||
return data.map((row: any) => ({
|
||||
...row,
|
||||
props: JSON.parse(row.props || '[]'),
|
||||
imports: JSON.parse(row.imports || '[]')
|
||||
imports: JSON.parse(row.imports || '[]'),
|
||||
tags: JSON.parse(row.tags || '[]')
|
||||
}))
|
||||
},
|
||||
|
||||
@@ -136,7 +145,8 @@ export const componentsApi = {
|
||||
return {
|
||||
...row,
|
||||
props: JSON.parse(row.props || '[]'),
|
||||
imports: JSON.parse(row.imports || '[]')
|
||||
imports: JSON.parse(row.imports || '[]'),
|
||||
tags: JSON.parse(row.tags || '[]')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -149,16 +159,25 @@ export const componentsApi = {
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<{ success: boolean; error?: string; usedBy?: { id: string; name: string }[] }> {
|
||||
const res = await fetch(`${API_URL}/api/components/${id}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
return { success: false, error: data.error || data.message, usedBy: data.usedBy }
|
||||
}
|
||||
return data
|
||||
async update(id: string, data: Partial<VueComponentDefinition>): Promise<{ success: boolean }> {
|
||||
const res = await fetch(`${API_URL}/api/components/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async deleteAll(): Promise<{ success: boolean }> {
|
||||
async restore(id: string): Promise<{ success: boolean }> {
|
||||
return this.update(id, { status: 'active' })
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<{ success: boolean; warning?: string }> {
|
||||
const res = await fetch(`${API_URL}/api/components/${id}`, { method: 'DELETE' })
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async archiveAll(): Promise<{ success: boolean }> {
|
||||
const res = await fetch(`${API_URL}/api/components`, { method: 'DELETE' })
|
||||
return res.json()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCanvasStore } from '../../../stores/canvas'
|
||||
import { useWindowsStore } from '../../../stores/windows'
|
||||
import {
|
||||
renderInlineComponent,
|
||||
componentsApi,
|
||||
type VueComponentDefinition
|
||||
} from '../../dynamicComponents'
|
||||
|
||||
@@ -144,7 +145,7 @@ export function createCanvasHandlers(): ToolConfig[] {
|
||||
},
|
||||
required: ['id', 'name', 'template']
|
||||
},
|
||||
handler: (args: {
|
||||
handler: async (args: {
|
||||
id: string
|
||||
name: string
|
||||
template: string
|
||||
@@ -195,6 +196,13 @@ export function createCanvasHandlers(): ToolConfig[] {
|
||||
|
||||
emitComponentRendered(args)
|
||||
|
||||
// Auto-save component to database
|
||||
try {
|
||||
await componentsApi.save(definition)
|
||||
} catch (e) {
|
||||
console.warn('[auto-save] Failed to save component:', e)
|
||||
}
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
|
||||
return `Componente Vue "${args.name}" renderizado en ventana flotante`
|
||||
|
||||
@@ -34,11 +34,12 @@ export function createComponentHandlers(): ToolConfig[] {
|
||||
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' }
|
||||
imports: { type: 'array', items: { type: 'string' }, description: 'Imports de Vue' },
|
||||
tags: { type: 'array', items: { type: 'string' }, description: 'Etiquetas para categorizar el componente' }
|
||||
},
|
||||
required: ['name', 'template']
|
||||
},
|
||||
handler: async (args: Omit<VueComponentDefinition, 'id'> & { id?: string }) => {
|
||||
handler: async (args: Omit<VueComponentDefinition, 'id'> & { id?: string; tags?: string[] }) => {
|
||||
try {
|
||||
const result = await componentsApi.save({
|
||||
id: args.id || `comp-${Date.now()}`,
|
||||
@@ -47,7 +48,8 @@ export function createComponentHandlers(): ToolConfig[] {
|
||||
setup: args.setup,
|
||||
style: args.style,
|
||||
props: args.props,
|
||||
imports: args.imports
|
||||
imports: args.imports,
|
||||
tags: args.tags
|
||||
})
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.addToHistory({ tool: 'save_vue_component', args, timestamp: Date.now() })
|
||||
@@ -105,20 +107,26 @@ export function createComponentHandlers(): ToolConfig[] {
|
||||
},
|
||||
{
|
||||
name: 'list_vue_components',
|
||||
description: 'Lista todos los componentes guardados',
|
||||
description: 'Lista todos los componentes guardados (activos)',
|
||||
category: 'component',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
properties: {
|
||||
include_archived: { type: 'boolean', description: 'Incluir componentes archivados' }
|
||||
}
|
||||
},
|
||||
handler: async () => {
|
||||
handler: async (args: { include_archived?: boolean }) => {
|
||||
try {
|
||||
const components = await componentsApi.getAll()
|
||||
const components = await componentsApi.getAll({ includeArchived: args.include_archived })
|
||||
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}`
|
||||
const list = components.map(c => {
|
||||
const tags = c.tags?.length ? ` [${c.tags.join(', ')}]` : ''
|
||||
const status = c.status === 'archived' ? ' (archivado)' : ''
|
||||
return `- ${c.id}: ${c.name}${tags}${status}`
|
||||
}).join('\n')
|
||||
return `Componentes guardados (${components.length}):\n${list}`
|
||||
} catch (e: any) {
|
||||
return `Error: ${e.message}`
|
||||
}
|
||||
@@ -126,7 +134,7 @@ export function createComponentHandlers(): ToolConfig[] {
|
||||
},
|
||||
{
|
||||
name: 'delete_vue_component',
|
||||
description: 'Elimina un componente de la base de datos',
|
||||
description: 'Archiva un componente (soft delete). El componente no se elimina, solo se oculta.',
|
||||
category: 'component',
|
||||
schema: {
|
||||
type: 'object',
|
||||
@@ -137,8 +145,9 @@ export function createComponentHandlers(): ToolConfig[] {
|
||||
},
|
||||
handler: async (args: { id: string }) => {
|
||||
try {
|
||||
await componentsApi.delete(args.id)
|
||||
return `Componente "${args.id}" eliminado`
|
||||
const result = await componentsApi.delete(args.id)
|
||||
const warning = result.warning ? ` (Nota: ${result.warning})` : ''
|
||||
return `Componente "${args.id}" archivado${warning}`
|
||||
} catch (e: any) {
|
||||
return `Error: ${e.message}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user