← back to Dw Design Bridge

server.js

290 lines

/**
 * dw-design-bridge — AI factory × designer-wallcoverings connector.
 * Born from idea-loop top idea (29/40) on 2026-05-07.
 *
 * Generates novel DW wallcovering design BRIEFS using local Ollama (qwen3:14b on Mac2).
 * Each brief = pattern concept + palette + scale + use-case + 1-line marketing copy.
 * Steve reviews briefs from the dashboard; promoted briefs become Shopify product proposals
 * (manual gate — never writes to dw_unified or Shopify directly).
 *
 * Endpoints:
 *   POST /api/jobs         body: {brief: string, count: number}  → {job_id}
 *   GET  /api/jobs         → list of recent jobs
 *   GET  /api/jobs/:id     → status + generated designs
 *   GET  /api/health       → liveness
 *   GET  /                 → dashboard
 */

const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const PORT = process.env.PORT || 9923;
const OLLAMA = process.env.OLLAMA || 'http://localhost:11434';
const MODEL = process.env.MODEL || 'qwen3:14b';
const JOBS_DIR = path.join(__dirname, 'jobs');

if (!fs.existsSync(JOBS_DIR)) fs.mkdirSync(JOBS_DIR, { recursive: true });

const app = express();
app.use(express.json({ limit: '256kb' }));
app.use(express.static(path.join(__dirname, 'public')));

function newId() {
  return new Date().toISOString().replace(/[:T.]/g, '-').slice(0, 19) + '-' + crypto.randomBytes(3).toString('hex');
}

function loadJob(id) {
  try {
    return JSON.parse(fs.readFileSync(path.join(JOBS_DIR, `${id}.json`), 'utf8'));
  } catch { return null; }
}
function saveJob(id, data) {
  fs.writeFileSync(path.join(JOBS_DIR, `${id}.json`), JSON.stringify(data, null, 2));
}

async function generateDesign(brief) {
  const prompt = `You are an expert wallcovering designer for Designer Wallcoverings (luxury B2B + trade).
Given a brief, propose ONE original wallcovering design.

Brief: ${brief}

Output STRICTLY one JSON object on a single line, no prose, no markdown fence:
{"name":"<≤40 chars, evocative product name>","pattern_concept":"<≤120 chars description>","palette":["#hex1","#hex2","#hex3","#hex4"],"scale":"small|medium|large|oversize","repeat":"<≤30 chars eg drop match 21in vertical>","material_pairing":"<silk|grasscloth|mylar|cork|vinyl|paper|cotton|linen>","style_tags":["<tag>","<tag>","<tag>"],"use_case":"<≤80 chars eg hotel lobby feature wall>","marketing_copy":"<≤100 chars one-liner>","trade_grade":"residential|class A","price_band":"$$|$$$|$$$$"}

/no_think — just emit the JSON object.`;

  const body = {
    model: MODEL,
    prompt,
    stream: false,
    options: { temperature: 0.85, num_predict: 600 }
  };

  const res = await fetch(`${OLLAMA}/api/generate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(180000)
  });
  if (!res.ok) throw new Error(`ollama http ${res.status}`);
  const j = await res.json();
  let raw = j.response || '';
  // Strip <think>…</think> (qwen3 reasoning leaks)
  raw = raw.replace(/<think>[\s\S]*?<\/think>/g, '');
  // Strip ```json fences if present
  raw = raw.replace(/```(?:json)?\s*/g, '').replace(/```/g, '');
  // Find FIRST balanced {...} object — try multiple strategies
  // 1) greedy match { ... } that contains "name" or "pattern_concept"
  let m = raw.match(/\{[^{}]*"name"[\s\S]*?\}/);
  // 2) any first {...}
  if (!m) m = raw.match(/\{[\s\S]*?\}(?=\s*$|\s*\n)/);
  if (!m) m = raw.match(/\{[\s\S]*\}/);
  if (!m) throw new Error('no JSON found in response');
  // Try parsing progressively — sometimes there's trailing junk after the JSON
  let candidate = m[0];
  let parsed = null;
  for (let i = candidate.length; i > 50; i--) {
    if (candidate[i - 1] !== '}') continue;
    try { parsed = JSON.parse(candidate.slice(0, i)); break; } catch {}
  }
  if (!parsed) throw new Error('JSON parse failed across all candidates');
  return parsed;
}

// SVG pattern generator — uses the design's palette + style_tags to render a tileable preview.
// Deterministic per (id, idx) so previews are stable. Runs free + instantly (no LLM call).
function patternSvg(design, seed) {
  const pal = (design.palette && design.palette.length) ? design.palette : ['#1a1a1a', '#c9a14b', '#e8e8e8', '#5a3a1a'];
  const tags = (design.style_tags || []).map(t => String(t).toLowerCase());
  const scale = design.scale || 'medium';
  const tileSize = { small: 80, medium: 140, large: 220, oversize: 320 }[scale] || 140;

  // Seeded PRNG so previews are stable
  let s = 0;
  for (let i = 0; i < seed.length; i++) s = (s * 31 + seed.charCodeAt(i)) | 0;
  const rand = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };

  // Pick motif family from style_tags
  let motif = 'geometric';
  if (tags.some(t => /botan|floral|leaf|garden|jungle|tropical/.test(t))) motif = 'botanical';
  else if (tags.some(t => /deco|art.deco|geometric|sunburst|fan/.test(t))) motif = 'deco';
  else if (tags.some(t => /damask|victorian|brocade|chinoiserie|toile/.test(t))) motif = 'damask';
  else if (tags.some(t => /op.art|memphis|pop|psychedelic|mod/.test(t))) motif = 'pop';
  else if (tags.some(t => /metallic|foil|chrome|mylar|silver|gold|gild/.test(t))) motif = 'metallic';
  else if (tags.some(t => /natural|grasscloth|cork|jute|raffia|woven|texture/.test(t))) motif = 'woven';
  else if (tags.some(t => /aged|distressed|patina|crackle/.test(t))) motif = 'patina';

  const fg = pal[1] || pal[0];
  const fg2 = pal[2] || fg;
  const accent = pal[3] || fg2;

  let pattern = '';
  if (motif === 'botanical') {
    // Stylized leaf cluster
    const cx = tileSize / 2, cy = tileSize / 2;
    pattern = `
      <ellipse cx="${cx}" cy="${cy - tileSize*0.18}" rx="${tileSize*0.14}" ry="${tileSize*0.32}" fill="${fg}" opacity="0.85" transform="rotate(-15 ${cx} ${cy})"/>
      <ellipse cx="${cx}" cy="${cy - tileSize*0.18}" rx="${tileSize*0.14}" ry="${tileSize*0.32}" fill="${fg2}" opacity="0.7" transform="rotate(20 ${cx} ${cy})"/>
      <circle cx="${cx}" cy="${cy + tileSize*0.18}" r="${tileSize*0.06}" fill="${accent}"/>
      <path d="M ${cx} ${cy + tileSize*0.05} Q ${cx - tileSize*0.18} ${cy - tileSize*0.08} ${cx - tileSize*0.32} ${cy + tileSize*0.18}" stroke="${fg}" stroke-width="2" fill="none"/>
      <path d="M ${cx} ${cy + tileSize*0.05} Q ${cx + tileSize*0.18} ${cy - tileSize*0.08} ${cx + tileSize*0.32} ${cy + tileSize*0.18}" stroke="${fg}" stroke-width="2" fill="none"/>
    `;
  } else if (motif === 'deco') {
    // Sunburst / fan
    const cx = tileSize / 2, cy = tileSize;
    let rays = '';
    for (let r = 0; r < 9; r++) {
      const a = (r / 8) * Math.PI - Math.PI;
      const x = cx + Math.cos(a) * tileSize * 0.7;
      const y = cy + Math.sin(a) * tileSize * 0.7;
      rays += `<line x1="${cx}" y1="${cy}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="${r % 2 ? fg : accent}" stroke-width="2"/>`;
    }
    pattern = rays + `<circle cx="${cx}" cy="${cy}" r="${tileSize*0.12}" fill="${fg2}"/>`;
  } else if (motif === 'damask') {
    // Center floret + corner mirror
    const cx = tileSize / 2, cy = tileSize / 2;
    pattern = `
      <path d="M ${cx} ${cy - tileSize*0.32} Q ${cx + tileSize*0.18} ${cy - tileSize*0.10} ${cx} ${cy} Q ${cx - tileSize*0.18} ${cy - tileSize*0.10} ${cx} ${cy - tileSize*0.32} Z" fill="${fg}"/>
      <path d="M ${cx} ${cy + tileSize*0.32} Q ${cx + tileSize*0.18} ${cy + tileSize*0.10} ${cx} ${cy} Q ${cx - tileSize*0.18} ${cy + tileSize*0.10} ${cx} ${cy + tileSize*0.32} Z" fill="${fg2}"/>
      <circle cx="${cx}" cy="${cy}" r="${tileSize*0.05}" fill="${accent}"/>
    `;
  } else if (motif === 'pop') {
    // Circles + dots
    let dots = '';
    for (let i = 0; i < 5; i++) {
      const cx = rand() * tileSize, cy = rand() * tileSize;
      const r = (tileSize * 0.06) + rand() * tileSize * 0.10;
      const c = [pal[0], fg, fg2, accent][Math.floor(rand() * 4)];
      dots += `<circle cx="${cx.toFixed(0)}" cy="${cy.toFixed(0)}" r="${r.toFixed(1)}" fill="${c}"/>`;
    }
    pattern = dots;
  } else if (motif === 'metallic') {
    // Diagonal lines + highlight
    let lines = '';
    for (let i = -2; i < 8; i++) {
      const x = i * tileSize / 6;
      lines += `<line x1="${x}" y1="0" x2="${x + tileSize}" y2="${tileSize}" stroke="${i % 2 ? fg : fg2}" stroke-width="${1.5 + (i % 3)}" opacity="0.7"/>`;
    }
    pattern = lines;
  } else if (motif === 'woven') {
    // Crosshatch weave
    let weave = '';
    const step = tileSize / 8;
    for (let i = 0; i < 8; i++) {
      const x = i * step;
      weave += `<rect x="${x}" y="0" width="${step*0.5}" height="${tileSize}" fill="${i % 2 ? fg : fg2}" opacity="0.8"/>`;
      weave += `<rect x="0" y="${x}" width="${tileSize}" height="${step*0.5}" fill="${i % 2 ? accent : fg2}" opacity="0.4"/>`;
    }
    pattern = weave;
  } else if (motif === 'patina') {
    // Distressed splotches
    let splotches = '';
    for (let i = 0; i < 12; i++) {
      const cx = rand() * tileSize, cy = rand() * tileSize;
      const rx = (tileSize * 0.04) + rand() * tileSize * 0.08;
      const ry = rx * (0.5 + rand());
      const c = [fg, fg2, accent][Math.floor(rand() * 3)];
      splotches += `<ellipse cx="${cx.toFixed(0)}" cy="${cy.toFixed(0)}" rx="${rx.toFixed(1)}" ry="${ry.toFixed(1)}" fill="${c}" opacity="${(0.3 + rand() * 0.5).toFixed(2)}"/>`;
    }
    pattern = splotches;
  } else {
    // geometric default — diamond grid
    const half = tileSize / 2;
    pattern = `
      <polygon points="${half},0 ${tileSize},${half} ${half},${tileSize} 0,${half}" fill="${fg}" opacity="0.85"/>
      <polygon points="${half},${tileSize*0.18} ${tileSize*0.82},${half} ${half},${tileSize*0.82} ${tileSize*0.18},${half}" fill="${fg2}"/>
      <circle cx="${half}" cy="${half}" r="${tileSize*0.06}" fill="${accent}"/>
    `;
  }

  // 2x2 tiled preview to show repeat
  const W = tileSize * 2, H = tileSize * 2;
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="100%" height="auto" preserveAspectRatio="xMidYMid slice">
    <defs>
      <pattern id="p${seed}" x="0" y="0" width="${tileSize}" height="${tileSize}" patternUnits="userSpaceOnUse">
        <rect width="${tileSize}" height="${tileSize}" fill="${pal[0]}"/>
        ${pattern}
      </pattern>
    </defs>
    <rect width="${W}" height="${H}" fill="url(#p${seed})"/>
  </svg>`;
}

async function runJob(id) {
  const job = loadJob(id);
  if (!job) return;
  job.status = 'running';
  job.started_at = new Date().toISOString();
  saveJob(id, job);

  const jobDir = path.join(JOBS_DIR, id);
  if (!fs.existsSync(jobDir)) fs.mkdirSync(jobDir, { recursive: true });

  const designs = [];
  for (let i = 0; i < job.count; i++) {
    try {
      const d = await generateDesign(job.brief);
      const seed = `${id}-${i}`;
      const svg = patternSvg(d, seed);
      const svgPath = path.join(jobDir, `${i}.svg`);
      fs.writeFileSync(svgPath, svg);
      designs.push({ idx: i, ok: true, design: d, preview_url: `/jobs/${id}/${i}.svg`, generated_at: new Date().toISOString() });
    } catch (e) {
      designs.push({ idx: i, ok: false, error: e.message, generated_at: new Date().toISOString() });
    }
    job.designs = designs;
    job.progress = `${i + 1}/${job.count}`;
    saveJob(id, job);
  }
  job.status = 'done';
  job.finished_at = new Date().toISOString();
  saveJob(id, job);
}

// Serve generated SVG previews
app.use('/jobs', express.static(JOBS_DIR));

app.post('/api/jobs', async (req, res) => {
  const brief = String(req.body.brief || '').trim().slice(0, 1000);
  const count = Math.max(1, Math.min(20, parseInt(req.body.count) || 5));
  if (!brief) return res.status(400).json({ error: 'brief required' });

  const id = newId();
  const job = {
    id,
    brief,
    count,
    status: 'queued',
    created_at: new Date().toISOString(),
    progress: `0/${count}`,
    designs: []
  };
  saveJob(id, job);
  setImmediate(() => runJob(id).catch(e => {
    const j = loadJob(id);
    if (j) { j.status = 'error'; j.error = e.message; saveJob(id, j); }
  }));
  res.json({ id, status: 'queued', count });
});

app.get('/api/jobs', (req, res) => {
  const files = fs.readdirSync(JOBS_DIR).filter(f => f.endsWith('.json')).sort().reverse().slice(0, 50);
  res.json({ jobs: files.map(f => loadJob(f.replace('.json', ''))).filter(Boolean) });
});

app.get('/api/jobs/:id', (req, res) => {
  const j = loadJob(req.params.id);
  if (!j) return res.status(404).json({ error: 'not found' });
  res.json(j);
});

app.get('/api/health', (req, res) => {
  res.json({ ok: true, ollama: OLLAMA, model: MODEL, jobs: fs.readdirSync(JOBS_DIR).filter(f => f.endsWith('.json')).length });
});

app.listen(PORT, '127.0.0.1', () => {
  console.log(`dw-design-bridge listening on http://127.0.0.1:${PORT}`);
});