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