feat: Replace DB component tools with filesystem-based user-components/

Components are now .vue files in user-components/<folder>/ parsed at runtime.
Replaces 6 DB MCP tools with 2 (list_fs_components, load_fs_component).
Adds vue-parser, fs-components API, and file watcher for live reload.
This commit is contained in:
2026-02-18 10:24:05 -06:00
parent e9451b2a47
commit d27da30494
13 changed files with 597 additions and 19 deletions

View File

@@ -15,3 +15,6 @@ export const DB_PATH = 'agent-ui.db'
// Recordings
export const RECORDINGS_DIR = 'recordings'
// User components
export const USER_COMPONENTS_DIR = 'user-components'

View File

@@ -0,0 +1,24 @@
import { join } from 'path'
import { jsonResponse, errorResponse } from '../utils/cors'
import { USER_COMPONENTS_DIR, WORKING_DIR } from '../config'
import { listAllComponents, parseComponentFolder } from '../services/vue-parser'
const baseDir = join(WORKING_DIR, USER_COMPONENTS_DIR)
export function handleFsComponents(req: Request) {
if (req.method !== 'GET') return null
const components = listAllComponents(baseDir)
return jsonResponse(components)
}
export function handleFsComponentByName(req: Request, folderName: string) {
if (req.method !== 'GET') return null
const component = parseComponentFolder(baseDir, folderName)
if (!component) {
return errorResponse(`Component folder "${folderName}" not found or has no .vue file`, 404)
}
return jsonResponse(component)
}

View File

@@ -3,6 +3,7 @@ import { handleHistory } from './history'
import { handleConfig, handleHealth } from './config'
import { handleWebMCPToken, handleWebMCPRequestToken } from './webmcp'
import { handleComponents, handleComponentById, handleComponentUsage } from './components'
import { handleFsComponents, handleFsComponentByName } from './fs-components'
import { handleThemes, handleActiveTheme, handleDesignTokens, handleThemeById, handleThemeExport } from './themes'
import { handleCanvas, handleCanvasById, handleToolbarCanvas, handleDefaultCanvas, handleCanvasComponents, handleCanvasComponentById } from './canvas'
import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea'
@@ -107,6 +108,18 @@ export async function handleRequest(req: Request): Promise<Response> {
if (res) return res
}
// Filesystem components
if (path === '/api/fs-components') {
const res = handleFsComponents(req)
if (res) return res
}
if (path.startsWith('/api/fs-components/')) {
const folderName = path.split('/').pop()!
const res = handleFsComponentByName(req, folderName)
if (res) return res
}
// Themes
if (path === '/api/themes') {
const res = await handleThemes(req)

View File

@@ -0,0 +1,67 @@
/**
* Components Handler
* Watches user-components/ for .vue and .json file changes
* and broadcasts notifications via the sync server.
*/
import { watch, mkdirSync, existsSync, type FSWatcher } from 'fs'
import { join } from 'path'
import { USER_COMPONENTS_DIR } from '../../config'
let componentsWatcher: FSWatcher | null = null
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const DEBOUNCE_MS = 400
export function setupComponentsWatcher(workingDir: string, broadcast: (message: string, filter?: (ws: any) => boolean) => void) {
const componentsDir = join(workingDir, USER_COMPONENTS_DIR)
// Auto-create directory if it doesn't exist
if (!existsSync(componentsDir)) {
try {
mkdirSync(componentsDir, { recursive: true })
} catch (e: any) {
console.error(`[Components] Failed to create ${componentsDir}: ${e.message}`)
return
}
}
try {
componentsWatcher = watch(componentsDir, { recursive: true }, (_, filename) => {
if (!filename) return
// Only watch .vue and .json files
if (!filename.endsWith('.vue') && !filename.endsWith('.json')) return
// Debounce
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
// Extract folder name from path (first segment)
const folder = filename.split(/[/\\]/)[0] || ''
const file = filename.split(/[/\\]/).pop() || filename
console.log(`[Components] Change: ${filename}`)
broadcast(JSON.stringify({
type: 'component-change',
folder,
file,
timestamp: Date.now()
}))
}, DEBOUNCE_MS)
})
console.log(`[Components] Watching ${componentsDir}`)
} catch (e: any) {
console.error(`[Components] Watch failed: ${e.message}`)
}
}
export function cleanupComponentsWatcher() {
if (componentsWatcher) {
componentsWatcher.close()
componentsWatcher = null
}
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}

View File

@@ -7,6 +7,7 @@
import { PORT_GIT, WORKING_DIR } from '../config'
import { setupGitWatcher, handleGitClient, cleanupGitWatcher } from './handlers/git-handler'
import { setupComponentsWatcher, cleanupComponentsWatcher } from './handlers/components-handler'
import { handleTorchMessage, handleTorchConnect, handleTorchDisconnect, getTorchStatus, cleanupTorchHandler } from './handlers/torch-handler'
// Connected clients
@@ -96,14 +97,16 @@ export function startSyncServer() {
console.log(`[Sync] WebSocket server on port ${PORT_GIT}`)
// Start git file watcher
// Start file watchers
setupGitWatcher(WORKING_DIR, broadcast)
setupComponentsWatcher(WORKING_DIR, broadcast)
return server
}
export function stopSyncServer() {
cleanupGitWatcher()
cleanupComponentsWatcher()
cleanupTorchHandler()
clients.clear()
}

View File

@@ -0,0 +1,117 @@
/**
* Vue File Parser
* Extracts <template>, <script setup>, <style> from .vue files
* and converts them into VueComponentDefinition objects.
*/
import { join } from 'path'
import { readdirSync, readFileSync, existsSync, statSync } from 'fs'
export interface VueComponentDefinition {
id: string
name: string
template: string
setup?: string
style?: string
props?: string[]
imports?: string[]
tags?: string[]
folder: string
}
export interface ComponentMeta {
name?: string
tags?: string[]
props?: string[]
imports?: string[]
}
/**
* Parse a .vue file content into template, setup, style sections
*/
export function parseVueFile(content: string): { template: string; setup: string; style: string } {
const templateMatch = content.match(/<template>([\s\S]*?)<\/template>/)
const scriptMatch = content.match(/<script\s+setup[^>]*>([\s\S]*?)<\/script>/)
const styleMatch = content.match(/<style[^>]*>([\s\S]*?)<\/style>/)
return {
template: templateMatch?.[1]?.trim() || '',
setup: scriptMatch?.[1]?.trim() || '',
style: styleMatch?.[1]?.trim() || ''
}
}
/**
* Read optional meta.json from a component folder
*/
export function readMetaJson(folderPath: string): ComponentMeta | null {
const metaPath = join(folderPath, 'meta.json')
if (!existsSync(metaPath)) return null
try {
const raw = readFileSync(metaPath, 'utf-8')
return JSON.parse(raw) as ComponentMeta
} catch {
return null
}
}
/**
* Parse a component folder into a VueComponentDefinition
* Convention: user-components/counter-widget/CounterWidget.vue + optional meta.json
*/
export function parseComponentFolder(baseDir: string, folderName: string): VueComponentDefinition | null {
const folderPath = join(baseDir, folderName)
if (!existsSync(folderPath) || !statSync(folderPath).isDirectory()) return null
// Find the .vue file in the folder
const files = readdirSync(folderPath)
const vueFile = files.find(f => f.endsWith('.vue'))
if (!vueFile) return null
const vueContent = readFileSync(join(folderPath, vueFile), 'utf-8')
const { template, setup, style } = parseVueFile(vueContent)
if (!template) return null
const meta = readMetaJson(folderPath)
// Derive name from meta or filename (CounterWidget.vue -> CounterWidget)
const nameFromFile = vueFile.replace('.vue', '')
const name = meta?.name || nameFromFile
return {
id: folderName,
name,
template,
setup: setup || undefined,
style: style || undefined,
props: meta?.props,
imports: meta?.imports,
tags: meta?.tags,
folder: folderName
}
}
/**
* List all component subdirectories and parse them
*/
export function listAllComponents(baseDir: string): VueComponentDefinition[] {
if (!existsSync(baseDir)) return []
const entries = readdirSync(baseDir)
const components: VueComponentDefinition[] = []
for (const entry of entries) {
const fullPath = join(baseDir, entry)
if (!statSync(fullPath).isDirectory()) continue
const component = parseComponentFolder(baseDir, entry)
if (component) {
components.push(component)
}
}
return components
}