← back to Qwen38 Viewer

server.js

130 lines

'use strict';
/**
 * qwen38-viewer — a minimal web chat front-end for the local uncensored
 * Qwen3.8-27B Heretic model served by Ollama on this Mac (macstudio3).
 *
 * The model runs in Ollama (localhost:11434); this server is a thin,
 * auth-gated proxy + static host. It MUST stay co-located with Ollama —
 * a remote host (Kamatera) cannot reach this box's 11434.
 */
const express = require('express');
const path = require('path');

const app = express();
const PORT = process.env.PORT || 9821;
const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const MODEL = process.env.MODEL || 'qwen3.8-27b-heretic';
const KEEP_ALIVE = process.env.KEEP_ALIVE || '-1'; // -1 = never unload (pinned warm, instant responses)

// ---- Basic auth gate — multi-user ------------------------------------------
// USERS env = "user1:pass1,user2:pass2". Falls back to admin/DW2024!.
const USERS = (process.env.USERS || 'admin:DW2024!')
  .split(',').map(s => s.trim()).filter(Boolean)
  .reduce((m, pair) => {
    const i = pair.indexOf(':');
    if (i > 0) m[pair.slice(0, i)] = pair.slice(i + 1);
    return m;
  }, {});
// Case-insensitive shared access codes (any username). CODES env = comma list.
const CI_CODES = new Set((process.env.CODES || 'Dust2026')
  .split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
app.use((req, res, next) => {
  if (req.path === '/health') return next(); // health is open for canaries
  const hdr = req.headers.authorization || '';
  const [scheme, b64] = hdr.split(' ');
  if (scheme === 'Basic' && b64) {
    const s = Buffer.from(b64, 'base64').toString();
    const i = s.indexOf(':');
    if (i > 0) {                                                     // require a colon + non-empty user
      const u = s.slice(0, i), p = s.slice(i + 1);
      if (USERS[u] === p) return next();                            // exact user:pass
      if (CI_CODES.has(p.toLowerCase())) return next();             // case-insensitive shared code
    }
  }
  res.set('WWW-Authenticate', 'Basic realm="qwen38"');
  return res.status(401).send('Auth required');
});

app.use(express.json({ limit: '1mb' }));
app.use(express.static(path.join(__dirname, 'public')));

app.get('/health', (_req, res) =>
  res.json({ status: 'PASS', model: MODEL, ollama: OLLAMA }));

// Model metadata for the UI
app.get('/api/model', async (_req, res) => {
  try {
    const r = await fetch(`${OLLAMA}/api/tags`);
    const j = await r.json();
    const m = (j.models || []).find(x => x.name.startsWith(MODEL)) || null;
    res.json({ model: MODEL, size: m && m.size, present: !!m });
  } catch (e) {
    res.status(502).json({ error: String(e) });
  }
});

// Streaming chat proxy -> Ollama /api/chat (NDJSON passthrough)
app.post('/api/chat', async (req, res) => {
  const messages = Array.isArray(req.body.messages) ? req.body.messages : [];
  if (!messages.length) return res.status(400).json({ error: 'messages required' });
  const options = {
    temperature: clamp(req.body.temperature, 0, 2, 0.8),
    num_predict: clamp(req.body.num_predict, 1, 8192, 1024),
  };
  const think = req.body.think === true;   // default false = fast, no reasoning
  res.setHeader('Content-Type', 'application/x-ndjson');
  res.setHeader('Cache-Control', 'no-cache');
  // Idle-timeout guard: if Ollama sends NO data for IDLE_MS (wedged / overloaded /
  // thrashing on a cold load) we abort and return a clean, actionable error instead
  // of hanging the browser for minutes. Reset on every chunk, so a slow-but-steady
  // stream never trips it.
  const IDLE_MS = Number(process.env.IDLE_MS) || 90000;
  const controller = new AbortController();
  let idle, aborted = false;
  const bump = () => { clearTimeout(idle); idle = setTimeout(() => { aborted = true; controller.abort(); }, IDLE_MS); };
  try {
    bump();
    const upstream = await fetch(`${OLLAMA}/api/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      signal: controller.signal,
      body: JSON.stringify({ model: MODEL, messages, stream: true, options, think,
        keep_alive: KEEP_ALIVE === '-1' ? -1 : KEEP_ALIVE }),  // -1 (number) = keep forever
    });
    if (!upstream.ok || !upstream.body) {
      if (upstream.body) upstream.body.cancel().catch(() => {}); // don't leak the TCP conn
      res.write(JSON.stringify({ error: `ollama ${upstream.status}` }) + '\n');
      return res.end();
    }
    // Abort upstream if the client disconnects
    const reader = upstream.body.getReader();
    const onClose = () => { clearTimeout(idle); reader.cancel().catch(() => {}); };
    req.once('close', onClose);
    const dec = new TextDecoder();
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      bump();                                 // got data -> reset the idle timer
      res.write(dec.decode(value, { stream: true }));
    }
    clearTimeout(idle);
    req.off('close', onClose);
    res.end();
  } catch (e) {
    clearTimeout(idle);
    const msg = aborted
      ? `model is not responding (overloaded or loading) — no output for ${IDLE_MS/1000}s, please retry`
      : String(e);
    res.write(JSON.stringify({ error: msg }) + '\n');
    res.end();
  }
});

function clamp(v, lo, hi, dflt) {
  const n = Number(v);
  return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt;
}

app.listen(PORT, '0.0.0.0', () =>
  console.log(`qwen38-viewer on :${PORT} -> ${OLLAMA} (${MODEL})`));