← back to Pattern Vault

whimsical-compare/server.js

218 lines

#!/usr/bin/env node
/**
 * Whimsical Top-100 compare tool.
 *   left  = Spoonflower's bestselling "whimsical" wallpaper (the original)
 *   right = OUR settlement-safe original interpretation
 * Inline chat per row (Anthropic, local-qwen fallback) to adjust the brief,
 * then Regenerate re-renders via Replicate SDXL and serves the new image in place.
 *
 * Internal admin tool — Basic Auth admin / DW2024!.
 */
const express = require('express');
const { execFile } = require('child_process');
const fs = require('fs');
const path = require('path');
const https = require('https');

const ROOT = __dirname;
const PG = 'postgresql:///dw_unified?host=/tmp';
const PORT = process.env.PORT || 9793; // stable port for a durable URL
const OURS = path.join(ROOT, 'public', 'ours');

// ---- secrets ----
function secret(name) {
  try {
    const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
    const m = env.match(new RegExp('^' + name + '=([^\\n]+)', 'm'));
    return m ? m[1].trim() : null;
  } catch { return null; }
}
const REPLICATE = secret('REPLICATE_API_TOKEN');
const ANTHROPIC = secret('ANTHROPIC_API_KEY');
const NEG = 'bird, birds, butterfly, butterflies, banana, bananas, banana leaves, grape, grapes, ' +
  'text, watermark, signature, logo, blurry, low quality, jpeg artifacts, seams, frame, border, vignette';

const app = express();
app.use(express.json({ limit: '1mb' }));

// ---- basic auth ----
app.use((req, res, next) => {
  const h = req.headers.authorization || '';
  const [, b64] = h.split(' ');
  const [u, p] = Buffer.from(b64 || '', 'base64').toString().split(':');
  if (u === 'admin' && p === 'DW2024!') return next();
  res.set('WWW-Authenticate', 'Basic realm="whimsical"').status(401).end('Auth required');
});

app.use(express.static(path.join(ROOT, 'public')));

// ---- db helper ----
function psql(sql) {
  return new Promise((resolve, reject) => {
    execFile('psql', [PG, '-t', '-A', '-F', '\t', '-c', sql], { maxBuffer: 32 * 1024 * 1024 },
      (e, out) => e ? reject(e) : resolve(out));
  });
}

app.get('/api/faces', (req, res) => {
  const mf = path.join(ROOT, 'public', 'faces', 'manifest.json');
  if (!fs.existsSync(mf)) return res.json({ count: 0, designs: [] });
  res.json(JSON.parse(fs.readFileSync(mf, 'utf8')));
});

app.get('/api/designs', async (req, res) => {
  try {
    const out = await psql(
      `SELECT rank, sf_design_id, coalesce(name,''), coalesce(designer,''), coalesce(orders,0),
              coalesce(num_favorites,0), coalesce(our_design_path,''), coalesce(our_brief,''),
              coalesce(settlement_verdict,''), coalesce(our_prompt,''), to_char(updated_at,'Mon DD, YYYY, HH12:MI AM')
       FROM whimsical_top100 ORDER BY rank`);
    const rows = out.trim().split('\n').filter(Boolean).map(l => {
      const [rank, id, name, designer, orders, favs, ourPath, brief, verdict, prompt, updated] = l.split('\t');
      return {
        rank: +rank, id, name, designer, orders: +orders, favs: +favs,
        original: `/originals/${id}.jpg`,
        ours: ourPath || null, brief, verdict, prompt, updated
      };
    });
    res.json(rows);
  } catch (e) { res.status(500).json({ error: String(e) }); }
});

// ---- inline chat: adjust the brief conversationally ----
app.post('/api/chat', async (req, res) => {
  const { id, message, history = [], brief = '', prompt = '' } = req.body || {};
  const sys = `You are a wallpaper design assistant helping refine OUR ORIGINAL version of a whimsical pattern.
Current brief: "${brief}"
Current image prompt: "${prompt}"
The user wants to adjust our version. Reply conversationally in 1-2 sentences, THEN on a new line output an updated image prompt prefixed exactly with "PROMPT: ".
HARD RULE: never include birds, butterflies, bananas, grapes (legal settlement). Keep it a seamless tileable wallpaper. Always our own original, never a copy.`;
  const msgs = [...history, { role: 'user', content: message }];
  try {
    let reply = await anthropicChat(sys, msgs);
    if (!reply) reply = await qwenChat(sys, msgs);
    const pm = reply.match(/PROMPT:\s*([\s\S]+)$/);
    let newPrompt = pm ? pm[1].trim() : null;
    if (newPrompt) newPrompt = newPrompt.replace(/\b(bird|birds|butterfly|butterflies|banana[s]?|grape[s]?)\b/gi, 'botanical sprig');
    const chat = reply.replace(/PROMPT:[\s\S]+$/, '').trim();
    res.json({ reply: chat || 'Updated.', prompt: newPrompt });
  } catch (e) { res.status(500).json({ error: String(e) }); }
});

// ---- regenerate: render new prompt via SDXL, serve in place ----
app.post('/api/regenerate', async (req, res) => {
  const { id, prompt } = req.body || {};
  if (!id || !prompt) return res.status(400).json({ error: 'id + prompt required' });
  try {
    const safe = prompt.replace(/\b(bird|birds|butterfly|butterflies|banana[s]?|grape[s]?)\b/gi, 'botanical sprig');
    const { url, secs } = await sdxl(safe, (parseInt(id, 10) % 100000) + Math.floor(Math.random() * 1000));
    const dest = path.join(OURS, `${id}.png`);
    await download(url, dest);
    // post-gen banned-element vision check (local qwen, $0)
    const banned = await visionBanned(dest);
    let verdict = 'OK';
    if (banned) { fs.unlinkSync(dest); verdict = 'BLOCK'; }
    const ourPath = verdict === 'OK' ? `/ours/${id}.png` : null;
    await psql(`UPDATE whimsical_top100 SET our_prompt='${safe.replace(/'/g, "''")}',
      our_design_path=${ourPath ? `'${ourPath}'` : 'NULL'}, settlement_verdict='${verdict}', updated_at=now()
      WHERE sf_design_id=${parseInt(id, 10)}`);
    const cost = (secs * 0.000725);
    res.json({ ok: verdict === 'OK', verdict, ours: ourPath ? `${ourPath}?t=${Date.now()}` : null,
      cost: `$${cost.toFixed(4)}`, secs: secs.toFixed(1),
      note: verdict === 'BLOCK' ? 'Settlement block — output contained a banned element, discarded.' : null });
  } catch (e) { res.status(500).json({ error: String(e) }); }
});

// ---- LLM helpers ----
function anthropicChat(sys, msgs) {
  if (!ANTHROPIC) return Promise.resolve(null);
  const body = JSON.stringify({
    model: 'claude-haiku-4-5-20251001', max_tokens: 400, system: sys,
    messages: msgs.map(m => ({ role: m.role, content: m.content }))
  });
  return new Promise((resolve) => {
    const r = https.request('https://api.anthropic.com/v1/messages', {
      method: 'POST', headers: {
        'content-type': 'application/json', 'x-api-key': ANTHROPIC, 'anthropic-version': '2023-06-01'
      }
    }, resp => {
      let d = ''; resp.on('data', c => d += c);
      resp.on('end', () => {
        try { const j = JSON.parse(d); resolve(j.content?.[0]?.text || null); }
        catch { resolve(null); }
      });
    });
    r.on('error', () => resolve(null)); r.write(body); r.end();
  });
}
function qwenChat(sys, msgs) {
  const body = JSON.stringify({
    model: 'qwen3:14b', stream: false,
    messages: [{ role: 'system', content: sys }, ...msgs], options: { temperature: 0.6 }
  });
  return new Promise((resolve) => {
    const http = require('http');
    const r = http.request('http://127.0.0.1:11434/api/chat', { method: 'POST',
      headers: { 'content-type': 'application/json' } }, resp => {
      let d = ''; resp.on('data', c => d += c);
      resp.on('end', () => { try { resolve(JSON.parse(d).message?.content || 'Updated.'); } catch { resolve('Updated.'); } });
    });
    r.on('error', () => resolve('Updated.')); r.write(body); r.end();
  });
}
function visionBanned(imgPath) {
  const b64 = fs.readFileSync(imgPath).toString('base64');
  const body = JSON.stringify({
    model: 'qwen2.5vl:7b', stream: false,
    prompt: 'Reply ONLY JSON {"bird":bool,"butterfly":bool,"banana":bool,"grape":bool} — true only if clearly depicted.',
    images: [b64], options: { temperature: 0.2 }
  });
  return new Promise((resolve) => {
    const http = require('http');
    const r = http.request('http://127.0.0.1:11434/api/generate', { method: 'POST',
      headers: { 'content-type': 'application/json' } }, resp => {
      let d = ''; resp.on('data', c => d += c);
      resp.on('end', () => {
        try { const j = JSON.parse(JSON.parse(d).response.match(/\{.*\}/s)[0]);
          resolve(!!(j.bird || j.butterfly || j.banana || j.grape)); }
        catch { resolve(false); }
      });
    });
    r.on('error', () => resolve(false)); r.write(body); r.end();
  });
}
function sdxl(prompt, seed) {
  const post = (url, payload) => new Promise((resolve, reject) => {
    const body = payload ? JSON.stringify(payload) : null;
    const r = https.request(url, { method: payload ? 'POST' : 'GET', headers: {
      'Authorization': `Bearer ${REPLICATE}`, 'Content-Type': 'application/json' } }, resp => {
      let d = ''; resp.on('data', c => d += c); resp.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } });
    });
    r.on('error', reject); if (body) r.write(body); r.end();
  });
  return (async () => {
    const ver = (await post('https://api.replicate.com/v1/models/stability-ai/sdxl')).latest_version.id;
    let pred = await post('https://api.replicate.com/v1/predictions', { version: ver, input: {
      prompt, negative_prompt: NEG, width: 1024, height: 1024, num_inference_steps: 30,
      guidance_scale: 7.5, seed, refine: 'no_refiner' } });
    for (let i = 0; i < 90 && !['succeeded', 'failed', 'canceled'].includes(pred.status); i++) {
      await new Promise(r => setTimeout(r, 2000)); pred = await post(pred.urls.get);
    }
    if (pred.status !== 'succeeded') throw new Error('gen ' + pred.status);
    const out = Array.isArray(pred.output) ? pred.output[0] : pred.output;
    return { url: out, secs: (pred.metrics && pred.metrics.predict_time) || 0 };
  })();
}
function download(url, dest) {
  return new Promise((resolve, reject) => {
    https.get(url, r => { const f = fs.createWriteStream(dest); r.pipe(f); f.on('finish', () => f.close(resolve)); })
      .on('error', reject);
  });
}

const server = app.listen(PORT, () => {
  const p = server.address().port;
  fs.writeFileSync(path.join(ROOT, '.port'), String(p));
  console.log(`whimsical compare → http://127.0.0.1:${p}  (admin / DW2024!)`);
});