← 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");
});