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 MODE_PATH = '/Users/aodhan/homeai-data/active-mode.json' const ACTIVE_STYLE_PATH = '/Users/aodhan/homeai-data/active-prompt-style.json' const PROMPT_STYLES_DIR = new URL('./homeai-agent/prompt-styles', import.meta.url).pathname const HA_TOKEN = process.env.HA_TOKEN || '' const GAZE_HOST = 'http://10.0.0.101:5782' const GAZE_API_KEY = process.env.GAZE_API_KEY || '' const DREAM_HOST = process.env.DREAM_HOST || 'http://10.0.0.101:3000' const DREAM_API_KEY = process.env.DREAM_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 const needsAuth = params.get('auth') === '1'; // use server-side HA_TOKEN 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; const opts = { rejectUnauthorized: false, timeout: 5000 }; if (needsAuth && HA_TOKEN) { opts.headers = { 'Authorization': `Bearer ${HA_TOKEN}` }; } await new Promise((resolve, reject) => { const reqObj = client.get(url, opts, (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) { // Helper to proxy a JSON GET to GAZE const proxyGazeJson = async (apiPath, res, fallback) => { if (!GAZE_API_KEY) { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify(fallback)) return } try { const http = await import('http') const url = new URL(`${GAZE_HOST}${apiPath}`) 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(fallback)) } } server.middlewares.use('/api/gaze/presets', async (req, res) => { await proxyGazeJson('/api/v1/presets', res, { presets: [] }) }) server.middlewares.use('/api/gaze/characters', async (req, res) => { await proxyGazeJson('/api/v1/characters', res, { characters: [] }) }) // Proxy cover image for a GAZE character (binary passthrough) server.middlewares.use(async (req, res, next) => { const match = req.url.match(/^\/api\/gaze\/character\/([a-zA-Z0-9_\-]+)\/cover/) if (!match) return next() const characterId = match[1] if (!GAZE_API_KEY) { res.writeHead(404) res.end() return } try { const { default: http } = await import('http') const url = new URL(`${GAZE_HOST}/api/v1/character/${characterId}/cover`) const r = http.get(url, { headers: { 'X-API-Key': GAZE_API_KEY }, timeout: 5000 }, (proxyRes) => { res.writeHead(proxyRes.statusCode, { 'Content-Type': proxyRes.headers['content-type'] || 'image/png', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=3600', }) proxyRes.pipe(res) }) r.on('error', () => { if (!res.headersSent) { res.writeHead(502); res.end() } }) r.on('timeout', () => { r.destroy(); if (!res.headersSent) { res.writeHead(504); res.end() } }) } catch { if (!res.headersSent) { res.writeHead(500); res.end() } } }) }, } } function dreamProxyPlugin() { const dreamHeaders = DREAM_API_KEY ? { 'X-API-Key': DREAM_API_KEY } : {} return { name: 'dream-proxy', configureServer(server) { // Helper: proxy a JSON GET to Dream const proxyDreamJson = async (apiPath, res) => { try { const http = await import('http') const url = new URL(`${DREAM_HOST}${apiPath}`) const proxyRes = await new Promise((resolve, reject) => { const r = http.default.get(url, { headers: dreamHeaders, 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({ characters: [], error: 'Dream unreachable' })) } } // List characters (compact) server.middlewares.use('/api/dream/characters', async (req, res, next) => { // Only handle exact path (not sub-paths like /api/dream/characters/abc/image) if (req.url !== '/' && req.url !== '' && !req.url.startsWith('?')) return next() const qs = req.url === '/' || req.url === '' ? '' : req.url await proxyDreamJson(`/api/characters${qs}`, res) }) // Character image (binary passthrough) server.middlewares.use(async (req, res, next) => { const match = req.url.match(/^\/api\/dream\/characters\/([^/]+)\/image/) if (!match) return next() const charId = match[1] try { const { default: http } = await import('http') const url = new URL(`${DREAM_HOST}/api/characters/${charId}/image`) const r = http.get(url, { headers: dreamHeaders, timeout: 5000 }, (proxyRes) => { res.writeHead(proxyRes.statusCode, { 'Content-Type': proxyRes.headers['content-type'] || 'image/png', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=3600', }) proxyRes.pipe(res) }) r.on('error', () => { if (!res.headersSent) { res.writeHead(502); res.end() } }) r.on('timeout', () => { r.destroy(); if (!res.headersSent) { res.writeHead(504); res.end() } }) } catch { if (!res.headersSent) { res.writeHead(500); res.end() } } }) // Get single character (full details) server.middlewares.use(async (req, res, next) => { const match = req.url.match(/^\/api\/dream\/characters\/([^/]+)\/?$/) if (!match) return next() await proxyDreamJson(`/api/characters/${match[1]}`, res) }) }, } } function memoryStoragePlugin() { return { name: 'memory-storage', configureServer(server) { // Proxy all /api/memories/* requests to the OpenClaw bridge (port 8081) // The bridge handles SQLite + vector search; dashboard is just a passthrough const proxyMemoryRequest = async (req, res) => { if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,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) // Reconstruct full path: /api/memories/... (req.url has the part after /api/memories) const targetPath = `/api/memories${req.url}` 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', ...(body.length > 0 ? { 'Content-Length': body.length } : {}), }, timeout: 30000, }, (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')) }) if (body.length > 0) proxyReq.write(body) proxyReq.end() }) } catch (err) { console.error(`[memory-proxy] 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/memories', proxyMemoryRequest) }, } } 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 modePlugin() { return { name: 'mode-api', configureServer(server) { server.middlewares.use('/api/mode', 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') const DEFAULT_MODE = { mode: 'private', cloud_provider: 'anthropic', cloud_model: 'claude-sonnet-4-20250514', local_model: 'ollama/qwen3.5:35b-a3b', overrides: {}, updated_at: '' } if (req.method === 'GET') { try { const raw = await readFile(MODE_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_MODE)) } 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()) data.updated_at = new Date().toISOString() await writeFile(MODE_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 promptStylePlugin() { return { name: 'prompt-style-api', configureServer(server) { // GET /api/prompt-styles — list all available styles server.middlewares.use('/api/prompt-styles', async (req, res, next) => { if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Allow-Headers': 'Content-Type' }) res.end() return } if (req.method !== 'GET') { next(); return } const { readdir, readFile } = await import('fs/promises') try { const files = (await readdir(PROMPT_STYLES_DIR)).filter(f => f.endsWith('.json')) const styles = [] for (const file of files) { try { const raw = await readFile(`${PROMPT_STYLES_DIR}/${file}`, 'utf-8') styles.push(JSON.parse(raw)) } catch { /* skip corrupt files */ } } // Sort: cloud group first, then local styles.sort((a, b) => { if (a.group !== b.group) return a.group === 'cloud' ? -1 : 1 return (a.id || '').localeCompare(b.id || '') }) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify(styles)) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } }) // GET/POST /api/prompt-style — active style server.middlewares.use('/api/prompt-style', async (req, res, next) => { // Avoid matching /api/prompt-styles (plural) const url = new URL(req.url, 'http://localhost') if (url.pathname !== '/') { next(); return } 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(ACTIVE_STYLE_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({ style: 'standard', updated_at: '' })) } 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()) const VALID_STYLES = ['quick', 'standard', 'creative', 'roleplayer', 'game-master', 'storyteller'] if (!data.style || !VALID_STYLES.includes(data.style)) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: `Invalid style. Valid: ${VALID_STYLES.join(', ')}` })) return } const state = { style: data.style, updated_at: new Date().toISOString() } await writeFile(ACTIVE_STYLE_PATH, JSON.stringify(state, null, 2)) res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }) res.end(JSON.stringify({ ok: true, ...state })) } catch (err) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: err.message })) } return } next() }) }, } } 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(), dreamProxyPlugin(), gazeProxyPlugin(), characterLookupPlugin(), healthCheckPlugin(), modePlugin(), promptStylePlugin(), bridgeProxyPlugin(), tailwindcss(), react(), ], server: { host: '0.0.0.0', port: 5173, }, })