- Replace faster-whisper with wyoming-mlx-whisper (whisper-large-v3-turbo, MLX Metal GPU) STT latency: 8.4s → 400ms for short voice commands - Add Qwen3.5-35B-A3B (MoE, 3B active params, Q8_0) to Ollama — 26.7 tok/s vs 5.4 tok/s (70B) - Add model preload launchd service to pin voice model in VRAM permanently - Fix HA tool calling: set commands.native=true, symlink ha-ctl to PATH - Add pipeline benchmark script (STT/LLM/TTS latency profiling) - Add service restart buttons and STT endpoint to dashboard - Bind Vite dev server to 0.0.0.0 for LAN access Total estimated pipeline latency: ~27s → ~4s Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
170 lines
6.4 KiB
JavaScript
170 lines
6.4 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.character-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 }));
|
|
}
|
|
});
|
|
|
|
// TTS preview proxy — forwards POST to OpenClaw bridge, returns audio
|
|
server.middlewares.use('/api/tts', async (req, res) => {
|
|
if (req.method !== 'POST') {
|
|
res.writeHead(405);
|
|
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/api/tts',
|
|
{ method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': body.length }, timeout: 30000 },
|
|
(proxyRes) => {
|
|
res.writeHead(proxyRes.statusCode, {
|
|
'Content-Type': proxyRes.headers['content-type'] || 'audio/wav',
|
|
});
|
|
proxyRes.pipe(res);
|
|
proxyRes.on('end', resolve);
|
|
}
|
|
);
|
|
proxyReq.on('error', reject);
|
|
proxyReq.on('timeout', () => { proxyReq.destroy(); reject(new Error('timeout')); });
|
|
proxyReq.write(body);
|
|
proxyReq.end();
|
|
});
|
|
} catch {
|
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'TTS bridge unreachable' }));
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
// https://vite.dev/config/
|
|
export default defineConfig({
|
|
plugins: [
|
|
healthCheckPlugin(),
|
|
tailwindcss(),
|
|
react(),
|
|
],
|
|
server: {
|
|
host: '0.0.0.0',
|
|
},
|
|
})
|