- Deploy Music Assistant on Pi (10.0.0.199:8095) with host networking for Chromecast mDNS discovery, Spotify + SMB library support - Switch primary LLM from Ollama to Claude Sonnet 4 (Anthropic API), local models remain as fallback - Add model info tag under each assistant message in dashboard chat, persisted in conversation JSON - Rewrite homeai-agent/setup.sh: loads .env, injects API keys into plists, symlinks plists to ~/Library/LaunchAgents/, smoke tests services - Update install_service() in common.sh to use symlinks instead of copies - Open UFW ports on Pi for Music Assistant (8095, 8097, 8927) - Add ANTHROPIC_API_KEY to openclaw + bridge launchd plists Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
787 lines
30 KiB
JavaScript
787 lines
30 KiB
JavaScript
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 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 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 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(),
|
|
modePlugin(),
|
|
bridgeProxyPlugin(),
|
|
tailwindcss(),
|
|
react(),
|
|
],
|
|
server: {
|
|
host: '0.0.0.0',
|
|
port: 5173,
|
|
},
|
|
})
|