← back to Voice Chat
server.js
188 lines
#!/usr/bin/env node
/**
* voice-chat — fully free local stack.
*
* Browser SpeechRecognition (free)
* → POST /api/chat (NDJSON streaming)
* → Ollama on Mac1 (192.168.1.133:11434, free)
* ← NDJSON tokens streamed back
* ← Browser SpeechSynthesis (free, native system voices)
*
* No API keys leave this box. Audio never touches this server (browser handles
* both STT and TTS natively). We only proxy the LLM call so the browser doesn't
* have to talk cross-origin to Mac1.
*
* Env (in .env or shell):
* OLLAMA_URL — default http://192.168.1.133:11434 (Mac1; flip to localhost to use Mac2 GPU)
* MODEL — default "qwen3:8b"
* PORT — default 9893
* SYSTEM_PROMPT — default short Steve-flavored prompt
* BASIC_AUTH — optional "user:pw"
*/
import express from "express";
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
async function loadEnvFile() {
for (const path of [
join(__dirname, ".env"),
]) {
try {
const txt = await fs.readFile(path, "utf-8");
for (const line of txt.split("\n")) {
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
if (!m) continue;
if (process.env[m[1]]) continue;
let v = m[2];
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
process.env[m[1]] = v;
}
} catch {}
}
}
await loadEnvFile();
const PORT = Number(process.env.PORT || 9893);
const OLLAMA_URL = (process.env.OLLAMA_URL || "http://192.168.1.133:11434").replace(/\/$/, "");
const MODEL = process.env.MODEL || "qwen3:8b";
const BASIC_AUTH = process.env.BASIC_AUTH || "";
const SYSTEM_PROMPT =
process.env.SYSTEM_PROMPT ||
"You are a brisk, candid voice assistant for Steve. Reply in 1-2 short sentences — this is a spoken conversation, not a written essay. Skip filler ('great question', 'sure'). No bullet lists, no markdown. If unsure, say so. Talk like a colleague.";
const app = express();
app.use(express.json({ limit: "1mb" }));
if (BASIC_AUTH) {
const expected = "Basic " + Buffer.from(BASIC_AUTH).toString("base64");
app.use((req, res, next) => {
if (req.path === "/api/health") return next();
if ((req.headers.authorization || "") !== expected) {
res.set("WWW-Authenticate", 'Basic realm="voice-chat"');
return res.status(401).send("auth required");
}
next();
});
}
app.get("/api/health", (req, res) => {
res.json({ ok: true, model: MODEL, backend: OLLAMA_URL });
});
// Quick Ollama liveness check (used by the UI to show backend status)
app.get("/api/backend", async (req, res) => {
try {
const r = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) });
const d = await r.json();
const models = (d.models || []).map(m => m.name);
res.json({ ok: true, url: OLLAMA_URL, model: MODEL, modelAvailable: models.includes(MODEL), modelsCount: models.length });
} catch (e) {
res.status(503).json({ ok: false, url: OLLAMA_URL, error: e.message });
}
});
/**
* POST /api/chat
* body: { messages: [{role:"user"|"assistant"|"system", content:"..."}] }
* response: text/event-stream — each event is a JSON line:
* {"delta":"some token"} while streaming
* {"done":true,"elapsedMs":...} at end
*/
app.post("/api/chat", async (req, res) => {
const { messages = [] } = req.body || {};
if (!Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({ error: "messages[] required" });
}
const full = [{ role: "system", content: SYSTEM_PROMPT }, ...messages];
const t0 = Date.now();
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.flushHeaders?.();
let ollamaRes;
try {
ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: MODEL,
stream: true,
messages: full,
options: {
temperature: 0.7,
num_predict: 200, // keep replies short for conversation
},
}),
});
} catch (e) {
res.write(`data: ${JSON.stringify({ error: "ollama unreachable: " + e.message })}\n\n`);
return res.end();
}
if (!ollamaRes.ok) {
const t = await ollamaRes.text();
res.write(`data: ${JSON.stringify({ error: `ollama ${ollamaRes.status}: ${t.slice(0,200)}` })}\n\n`);
return res.end();
}
// Ollama streams NDJSON. Convert to SSE.
const reader = ollamaRes.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let firstTokenAt = null;
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
let obj;
try { obj = JSON.parse(line); } catch { continue; }
const piece = obj.message?.content;
if (piece) {
if (firstTokenAt === null) firstTokenAt = Date.now();
res.write(`data: ${JSON.stringify({ delta: piece })}\n\n`);
}
if (obj.done) {
res.write(`data: ${JSON.stringify({
done: true,
elapsedMs: Date.now() - t0,
ttftMs: firstTokenAt ? firstTokenAt - t0 : null,
})}\n\n`);
}
}
}
} catch (e) {
res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`);
}
res.end();
});
app.use(express.static(join(__dirname, "public")));
// 404 guard — any path that fell past express.static (e.g. mistyped /api/*
// or a stale clean-URL) returns a structured 404 instead of Express's
// default HTML page, so callers don't mistake it for a 200.
app.use((req, res) => {
if (req.path.startsWith("/api/")) {
return res.status(404).json({ error: "not found", path: req.path });
}
res.status(404).type("txt").send("404 not found: " + req.path);
});
app.listen(PORT, () => {
console.log(`voice-chat ready on http://localhost:${PORT}`);
console.log(` backend=${OLLAMA_URL} model=${MODEL}`);
if (BASIC_AUTH) console.log(" basic-auth ON");
});