import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' const CHARACTERS_DIR = '/Users/aodhan/homeai-data/characters' const SATELLITE_MAP_PATH = '/Users/aodhan/homeai-data/satellite-map.json' const CONVERSATIONS_DIR = '/Users/aodhan/homeai-data/conversations' const MEMORIES_DIR = '/Users/aodhan/homeai-data/memories' const GAZE_HOST = 'http://10.0.0.101:5782' const GAZE_API_KEY = process.env.GAZE_API_KEY || '' function characterStoragePlugin() { return { name: 'character-storage', configureServer(server) { const ensureDir = async () => { const { mkdir } = await import('fs/promises') await mkdir(CHARACTERS_DIR, { recursive: true }) } // GET /api/characters — list all profiles server.middlewares.use('/api/characters', async (req, res, next) => { if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,DELETE', 'Access-Control-Allow-Headers': 'Content-Type' }) res.end() return } const { readdir, readFile, writeFile, unlink } = await import('fs/promises') await ensureDir() // req.url has the mount prefix stripped by connect, so "/" means /api/characters const url = new URL(req.url, 'http://localhost') const subPath = url.pathname.replace(/^\/+/, '') // GET /api/characters/:id — single profile if (req.method === 'GET' && subPath) { try { const safeId = subPath.replace(/[^a-zA-Z0-9_\-\.]/g, '_') const raw = await readFile(`${CHARACTERS_DIR}/${safeId}.json`, 'utf-8') res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(raw) } catch { res.writeHead(404, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ error: 'Not found' })) } return } if (req.method === 'GET' && !subPath) { try { const files = (await readdir(CHARACTERS_DIR)).filter(f => f.endsWith('.json')) const profiles = [] for (const file of files) { try { const raw = await readFile(`${CHARACTERS_DIR}/${file}`, 'utf-8') profiles.push(JSON.parse(raw)) } catch { /* skip corrupt files */ } } // Sort by addedAt descending profiles.sort((a, b) => (b.addedAt || '').localeCompare(a.addedAt || '')) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify(profiles)) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } return } if (req.method === 'POST' && !subPath) { try { const chunks = [] for await (const chunk of req) chunks.push(chunk) const profile = JSON.parse(Buffer.concat(chunks).toString()) if (!profile.id) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Missing profile id' })) return } // Sanitize filename — only allow alphanumeric, underscore, dash, dot const safeId = profile.id.replace(/[^a-zA-Z0-9_\-\.]/g, '_') await writeFile(`${CHARACTERS_DIR}/${safeId}.json`, JSON.stringify(profile, null, 2)) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ ok: true })) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } return } if (req.method === 'DELETE' && subPath) { try { const safeId = subPath.replace(/[^a-zA-Z0-9_\-\.]/g, '_') await unlink(`${CHARACTERS_DIR}/${safeId}.json`).catch(() => {}) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ ok: true })) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } return } next() }) }, } } function satelliteMapPlugin() { return { name: 'satellite-map', configureServer(server) { server.middlewares.use('/api/satellite-map', async (req, res, next) => { if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST', 'Access-Control-Allow-Headers': 'Content-Type' }) res.end() return } const { readFile, writeFile } = await import('fs/promises') if (req.method === 'GET') { try { const raw = await readFile(SATELLITE_MAP_PATH, 'utf-8') res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(raw) } catch { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ default: 'aria_default', satellites: {} })) } return } if (req.method === 'POST') { try { const chunks = [] for await (const chunk of req) chunks.push(chunk) const data = JSON.parse(Buffer.concat(chunks).toString()) await writeFile(SATELLITE_MAP_PATH, JSON.stringify(data, null, 2)) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ ok: true })) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } return } next() }) }, } } function conversationStoragePlugin() { return { name: 'conversation-storage', configureServer(server) { const ensureDir = async () => { const { mkdir } = await import('fs/promises') await mkdir(CONVERSATIONS_DIR, { recursive: true }) } server.middlewares.use('/api/conversations', async (req, res, next) => { if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,DELETE', 'Access-Control-Allow-Headers': 'Content-Type' }) res.end() return } const { readdir, readFile, writeFile, unlink } = await import('fs/promises') await ensureDir() const url = new URL(req.url, 'http://localhost') const subPath = url.pathname.replace(/^\/+/, '') // GET /api/conversations/:id — single conversation with messages if (req.method === 'GET' && subPath) { try { const safeId = subPath.replace(/[^a-zA-Z0-9_\-\.]/g, '_') const raw = await readFile(`${CONVERSATIONS_DIR}/${safeId}.json`, 'utf-8') res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(raw) } catch { res.writeHead(404, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ error: 'Not found' })) } return } // GET /api/conversations — list metadata (no messages) if (req.method === 'GET' && !subPath) { try { const files = (await readdir(CONVERSATIONS_DIR)).filter(f => f.endsWith('.json')) const list = [] for (const file of files) { try { const raw = await readFile(`${CONVERSATIONS_DIR}/${file}`, 'utf-8') const conv = JSON.parse(raw) list.push({ id: conv.id, title: conv.title || '', characterId: conv.characterId || '', characterName: conv.characterName || '', createdAt: conv.createdAt || '', updatedAt: conv.updatedAt || '', messageCount: (conv.messages || []).length, }) } catch { /* skip corrupt files */ } } list.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify(list)) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } return } // POST /api/conversations — create or update if (req.method === 'POST' && !subPath) { try { const chunks = [] for await (const chunk of req) chunks.push(chunk) const conv = JSON.parse(Buffer.concat(chunks).toString()) if (!conv.id) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Missing conversation id' })) return } const safeId = conv.id.replace(/[^a-zA-Z0-9_\-\.]/g, '_') await writeFile(`${CONVERSATIONS_DIR}/${safeId}.json`, JSON.stringify(conv, null, 2)) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ ok: true })) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } return } // DELETE /api/conversations/:id if (req.method === 'DELETE' && subPath) { try { const safeId = subPath.replace(/[^a-zA-Z0-9_\-\.]/g, '_') await unlink(`${CONVERSATIONS_DIR}/${safeId}.json`).catch(() => {}) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ ok: true })) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } return } next() }) }, } } function healthCheckPlugin() { return { name: 'health-check-proxy', configureServer(server) { server.middlewares.use('/api/health', async (req, res) => { const params = new URL(req.url, 'http://localhost').searchParams; const url = params.get('url'); const mode = params.get('mode'); // 'tcp' for raw TCP port check if (!url) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Missing url param' })); return; } const start = Date.now(); const parsedUrl = new URL(url); try { if (mode === 'tcp') { // TCP socket connect check for non-HTTP services (e.g. Wyoming) const { default: net } = await import('net'); await new Promise((resolve, reject) => { const socket = net.createConnection( { host: parsedUrl.hostname, port: parseInt(parsedUrl.port), timeout: 5000 }, () => { socket.destroy(); resolve(); } ); socket.on('error', reject); socket.on('timeout', () => { socket.destroy(); reject(new Error('timeout')); }); }); } else { // HTTP/HTTPS health check const { default: https } = await import('https'); const { default: http } = await import('http'); const client = parsedUrl.protocol === 'https:' ? https : http; await new Promise((resolve, reject) => { const reqObj = client.get(url, { rejectUnauthorized: false, timeout: 5000 }, (resp) => { resp.resume(); resolve(); }); reqObj.on('error', reject); reqObj.on('timeout', () => { reqObj.destroy(); reject(new Error('timeout')); }); }); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'online', responseTime: Date.now() - start })); } catch { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'offline', responseTime: null })); } }); // Service restart — runs launchctl or docker restart server.middlewares.use('/api/service/restart', async (req, res) => { if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': 'Content-Type' }); res.end(); return; } if (req.method !== 'POST') { res.writeHead(405); res.end(); return; } try { const chunks = []; for await (const chunk of req) chunks.push(chunk); const { type, id } = JSON.parse(Buffer.concat(chunks).toString()); if (!type || !id) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'Missing type or id' })); return; } // Whitelist valid service IDs to prevent command injection const ALLOWED_LAUNCHD = [ 'gui/501/com.homeai.ollama', 'gui/501/com.homeai.openclaw', 'gui/501/com.homeai.openclaw-bridge', 'gui/501/com.homeai.wyoming-stt', 'gui/501/com.homeai.wyoming-tts', 'gui/501/com.homeai.wyoming-satellite', 'gui/501/com.homeai.dashboard', ]; const ALLOWED_DOCKER = [ 'homeai-open-webui', 'homeai-uptime-kuma', 'homeai-n8n', 'homeai-code-server', ]; let cmd; if (type === 'launchd' && ALLOWED_LAUNCHD.includes(id)) { cmd = ['launchctl', 'kickstart', '-k', id]; } else if (type === 'docker' && ALLOWED_DOCKER.includes(id)) { cmd = ['docker', 'restart', id]; } else { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'Service not in allowed list' })); return; } const { execFile } = await import('child_process'); const { promisify } = await import('util'); const execFileAsync = promisify(execFile); const { stdout, stderr } = await execFileAsync(cmd[0], cmd.slice(1), { timeout: 30000 }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, stdout: stdout.trim(), stderr: stderr.trim() })); } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: err.message })); } }); }, }; } function gazeProxyPlugin() { return { name: 'gaze-proxy', configureServer(server) { server.middlewares.use('/api/gaze/presets', async (req, res) => { if (!GAZE_API_KEY) { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ presets: [] })) return } try { const http = await import('http') const url = new URL(`${GAZE_HOST}/api/v1/presets`) const proxyRes = await new Promise((resolve, reject) => { const r = http.default.get(url, { headers: { 'X-API-Key': GAZE_API_KEY }, timeout: 5000 }, resolve) r.on('error', reject) r.on('timeout', () => { r.destroy(); reject(new Error('timeout')) }) }) const chunks = [] for await (const chunk of proxyRes) chunks.push(chunk) res.writeHead(proxyRes.statusCode, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(Buffer.concat(chunks)) } catch { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ presets: [] })) } }) }, } } function memoryStoragePlugin() { return { name: 'memory-storage', configureServer(server) { const ensureDirs = async () => { const { mkdir } = await import('fs/promises') await mkdir(`${MEMORIES_DIR}/personal`, { recursive: true }) } const readJsonFile = async (path, fallback) => { const { readFile } = await import('fs/promises') try { return JSON.parse(await readFile(path, 'utf-8')) } catch { return fallback } } const writeJsonFile = async (path, data) => { const { writeFile } = await import('fs/promises') await writeFile(path, JSON.stringify(data, null, 2)) } // Personal memories: /api/memories/personal/:characterId[/:memoryId] server.middlewares.use('/api/memories/personal', async (req, res, next) => { if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,DELETE', 'Access-Control-Allow-Headers': 'Content-Type' }) res.end() return } await ensureDirs() const url = new URL(req.url, 'http://localhost') const parts = url.pathname.replace(/^\/+/, '').split('/') const characterId = parts[0] ? parts[0].replace(/[^a-zA-Z0-9_\-\.]/g, '_') : null const memoryId = parts[1] || null if (!characterId) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Missing character ID' })) return } const filePath = `${MEMORIES_DIR}/personal/${characterId}.json` if (req.method === 'GET') { const data = await readJsonFile(filePath, { characterId, memories: [] }) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify(data)) return } if (req.method === 'POST') { try { const chunks = [] for await (const chunk of req) chunks.push(chunk) const memory = JSON.parse(Buffer.concat(chunks).toString()) const data = await readJsonFile(filePath, { characterId, memories: [] }) if (memory.id) { const idx = data.memories.findIndex(m => m.id === memory.id) if (idx >= 0) { data.memories[idx] = { ...data.memories[idx], ...memory } } else { data.memories.push(memory) } } else { memory.id = 'm_' + Date.now() memory.createdAt = memory.createdAt || new Date().toISOString() data.memories.push(memory) } await writeJsonFile(filePath, data) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ ok: true, memory })) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } return } if (req.method === 'DELETE' && memoryId) { try { const data = await readJsonFile(filePath, { characterId, memories: [] }) data.memories = data.memories.filter(m => m.id !== memoryId) await writeJsonFile(filePath, data) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ ok: true })) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } return } next() }) // General memories: /api/memories/general[/:memoryId] server.middlewares.use('/api/memories/general', async (req, res, next) => { if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,DELETE', 'Access-Control-Allow-Headers': 'Content-Type' }) res.end() return } await ensureDirs() const url = new URL(req.url, 'http://localhost') const memoryId = url.pathname.replace(/^\/+/, '') || null const filePath = `${MEMORIES_DIR}/general.json` if (req.method === 'GET') { const data = await readJsonFile(filePath, { memories: [] }) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify(data)) return } if (req.method === 'POST') { try { const chunks = [] for await (const chunk of req) chunks.push(chunk) const memory = JSON.parse(Buffer.concat(chunks).toString()) const data = await readJsonFile(filePath, { memories: [] }) if (memory.id) { const idx = data.memories.findIndex(m => m.id === memory.id) if (idx >= 0) { data.memories[idx] = { ...data.memories[idx], ...memory } } else { data.memories.push(memory) } } else { memory.id = 'm_' + Date.now() memory.createdAt = memory.createdAt || new Date().toISOString() data.memories.push(memory) } await writeJsonFile(filePath, data) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ ok: true, memory })) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } return } if (req.method === 'DELETE' && memoryId) { try { const data = await readJsonFile(filePath, { memories: [] }) data.memories = data.memories.filter(m => m.id !== memoryId) await writeJsonFile(filePath, data) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ ok: true })) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } return } next() }) }, } } function characterLookupPlugin() { return { name: 'character-lookup', configureServer(server) { server.middlewares.use('/api/character-lookup', async (req, res) => { if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': 'Content-Type' }) res.end() return } if (req.method !== 'POST') { res.writeHead(405, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'POST only' })) return } try { const chunks = [] for await (const chunk of req) chunks.push(chunk) const { name, franchise } = JSON.parse(Buffer.concat(chunks).toString()) if (!name || !franchise) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Missing name or franchise' })) return } const { execFile } = await import('child_process') const { promisify } = await import('util') const execFileAsync = promisify(execFile) // Call the MCP fetcher inside the running Docker container const safeName = name.replace(/'/g, "\\'") const safeFranchise = franchise.replace(/'/g, "\\'") const pyScript = ` import asyncio, json from character_details.fetcher import fetch_character c = asyncio.run(fetch_character('${safeName}', '${safeFranchise}')) print(json.dumps(c.model_dump(), default=str)) `.trim() const { stdout } = await execFileAsync( 'docker', ['exec', 'character-browser-character-mcp-1', 'python', '-c', pyScript], { timeout: 30000 } ) const data = JSON.parse(stdout.trim()) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ name: data.name || name, franchise: data.franchise || franchise, description: data.description || '', background: data.background || '', appearance: data.appearance || '', personality: data.personality || '', abilities: data.abilities || [], notable_quotes: data.notable_quotes || [], relationships: data.relationships || [], sources: data.sources || [], })) } catch (err) { console.error('[character-lookup] failed:', err?.message || err) const status = err?.message?.includes('timeout') ? 504 : 500 res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ error: err?.message || 'Lookup failed' })) } }) }, } } function bridgeProxyPlugin() { return { name: 'bridge-proxy', configureServer(server) { // Proxy a request to the OpenClaw bridge const proxyRequest = (targetPath) => async (req, res) => { if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }) res.end() return } try { const { default: http } = await import('http') const chunks = [] for await (const chunk of req) chunks.push(chunk) const body = Buffer.concat(chunks) await new Promise((resolve, reject) => { const proxyReq = http.request( `http://localhost:8081${targetPath}`, { method: req.method, headers: { 'Content-Type': req.headers['content-type'] || 'application/json', 'Content-Length': body.length, }, timeout: 120000, }, (proxyRes) => { res.writeHead(proxyRes.statusCode, { 'Content-Type': proxyRes.headers['content-type'] || 'application/json', 'Access-Control-Allow-Origin': '*', }) proxyRes.pipe(res) proxyRes.on('end', resolve) proxyRes.on('error', resolve) } ) proxyReq.on('error', reject) proxyReq.on('timeout', () => { proxyReq.destroy() reject(new Error('timeout')) }) proxyReq.write(body) proxyReq.end() }) } catch (err) { console.error(`[bridge-proxy] ${targetPath} failed:`, err?.message || err) if (!res.headersSent) { res.writeHead(502, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: `Bridge unreachable: ${err?.message || 'unknown'}` })) } } } server.middlewares.use('/api/agent/message', proxyRequest('/api/agent/message')) server.middlewares.use('/api/tts', proxyRequest('/api/tts')) server.middlewares.use('/api/stt', proxyRequest('/api/stt')) }, } } export default defineConfig({ plugins: [ characterStoragePlugin(), satelliteMapPlugin(), conversationStoragePlugin(), memoryStoragePlugin(), gazeProxyPlugin(), characterLookupPlugin(), healthCheckPlugin(), bridgeProxyPlugin(), tailwindcss(), react(), ], server: { host: '0.0.0.0', port: 5173, }, })