Combines homeai-character (service status, character profiles, editor) and homeai-desktop (chat with voice I/O) into homeai-dashboard on port 5173. - 4-page sidebar layout: Dashboard, Chat, Characters, Editor - Merged Vite middleware: health checks, service restart, bridge proxy - Bridge upgraded to ThreadingHTTPServer (fixes LAN request queuing) - TTS strips emojis before synthesis - Updated start.sh with new launchd service names - Added preload-models to startup sequence Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
202 lines
7.2 KiB
JavaScript
202 lines
7.2 KiB
JavaScript
import { defineConfig } from 'vite'
|
|
import react from '@vitejs/plugin-react'
|
|
import tailwindcss from '@tailwindcss/vite'
|
|
|
|
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 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 {
|
|
if (!res.headersSent) {
|
|
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ error: 'Bridge unreachable' }))
|
|
}
|
|
}
|
|
}
|
|
|
|
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: [
|
|
healthCheckPlugin(),
|
|
bridgeProxyPlugin(),
|
|
tailwindcss(),
|
|
react(),
|
|
],
|
|
server: {
|
|
host: '0.0.0.0',
|
|
port: 5173,
|
|
},
|
|
})
|