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:
@@ -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'
|
||||
|
||||
24
server/routes/fs-components.ts
Normal file
24
server/routes/fs-components.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
67
server/services/handlers/components-handler.ts
Normal file
67
server/services/handlers/components-handler.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
117
server/services/vue-parser.ts
Normal file
117
server/services/vue-parser.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user