← back to Wallco Ai
wallco.ai: hot-path fallback for /design/:id (live PG lookup when not in snapshot) · classification-filter PD: 73 AI-input candidates (34 seamless+textile + 39 near+textile) from 304-row corpus · is_ai_input_candidate column + index
2922a592ec3e5d4309c59a3ddac509ad24221c04 · 2026-05-11 17:41:39 -0700 · Steve
Files touched
M data/designs.jsonA data/moodboards.jsonA data/vision-cache.jsonM server.jsM src/review.js
Diff
commit 2922a592ec3e5d4309c59a3ddac509ad24221c04
Author: Steve <steve@designerwallcoverings.com>
Date: Mon May 11 17:41:39 2026 -0700
wallco.ai: hot-path fallback for /design/:id (live PG lookup when not in snapshot) · classification-filter PD: 73 AI-input candidates (34 seamless+textile + 39 near+textile) from 304-row corpus · is_ai_input_candidate column + index
---
data/designs.json | 15 ++++
data/moodboards.json | 1 +
data/vision-cache.json | 1 +
server.js | 34 ++++++++-
src/review.js | 199 ++++++++++++++++++++++++++++++++++++++++++++++---
5 files changed, 237 insertions(+), 13 deletions(-)
diff --git a/data/designs.json b/data/designs.json
index 3f63e9b..fac2dd9 100644
--- a/data/designs.json
+++ b/data/designs.json
@@ -448,5 +448,20 @@
"seed": 1617564843,
"is_published": false,
"created_at": "2026-05-11T23:48:57.994123+00:00"
+ },
+ {
+ "id": 41,
+ "kind": "seamless_tile",
+ "category": "mixed",
+ "dominant_hex": "#2c1323",
+ "saturation": 0.397,
+ "title": "Plum Studio No.41",
+ "handle": "wallco-0041",
+ "image_url": "/designs/img/1778543777405_536522826.png",
+ "filename": "1778543777405_536522826.png",
+ "generator": "Replicate SDXL",
+ "seed": 536522826,
+ "is_published": false,
+ "created_at": "2026-05-11T23:56:17.861881+00:00"
}
]
\ No newline at end of file
diff --git a/data/moodboards.json b/data/moodboards.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/data/moodboards.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/data/vision-cache.json b/data/vision-cache.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/data/vision-cache.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/server.js b/server.js
index 1c8e6e8..75174de 100644
--- a/server.js
+++ b/server.js
@@ -782,8 +782,15 @@ ${isAdmin ? `
.pair-card .pair-type { display:inline-block; font-size:9px; text-transform:uppercase; letter-spacing:.08em; padding:1px 6px; border-radius:2px; background:#2a2825; color:#d2b15c; margin-bottom:5px; }
.pair-card .pair-reason { font-size:11px; color:#bba; line-height:1.4; }
.pair-card .pair-hexcode { font-family: ui-monospace, monospace; font-size:10px; color:#888; margin-left:6px; }
- .pair-card a.pair-link { color:inherit; text-decoration:none; display:flex; gap:12px; align-items:flex-start; }
+ .pair-card a.pair-link { color:inherit; text-decoration:none; display:flex; gap:12px; align-items:flex-start; flex:1; }
.pair-card a.pair-link:hover .pair-title { color:#d2b15c; }
+ .pair-card .pair-pin { background:none; border:1px solid #3a3631; color:#666; font-size:14px; cursor:pointer; padding:3px 8px; border-radius:14px; transition:all .12s; flex-shrink:0; align-self:flex-start; }
+ .pair-card .pair-pin:hover { color:#d2b15c; border-color:#d2b15c; }
+ .pair-card.pinned { border-color:var(--chip-hot); background:#1f1b13; }
+ .pair-card.pinned .pair-pin { color:#d2b15c; background:#d2b15c33; border-color:#d2b15c; }
+ .pair-card .pair-vision-badge { display:inline-block; font-size:9px; padding:0 5px; border:1px solid #3a3631; border-radius:2px; color:#888; margin-left:6px; text-transform:uppercase; letter-spacing:.05em; }
+ #pair-vision-read { background:#1a1816; padding:8px 12px; margin:0 18px 8px; border:1px solid #2a2825; border-radius:2px; font-size:11px; color:#bba; line-height:1.45; font-style:italic; }
+ #pair-vision-read.hidden { display:none; }
.why-chip { display:none; padding:6px 9px; background:#16140f; border:1px solid #3a3631; border-radius:14px; font-size:11px; color:#d2b15c; margin-bottom:8px; cursor:pointer; line-height:1.35; }
.why-chip.visible { display:block; }
.why-chip.loading::after { content:' …'; opacity:.6; }
@@ -832,9 +839,10 @@ ${isAdmin ? `
<div class="ctx-main" id="pair-design-title">Pairs well with…</div>
<div class="ctx-sub" id="pair-design-sub"></div>
</div>
- <button class="refresh" id="pair-refresh" title="Force regenerate (Ollama)">↻</button>
+ <button class="refresh" id="pair-refresh" title="Force regenerate (Ollama + LLaVA)">↻</button>
<button class="close" id="pair-close" title="Close (Esc)">×</button>
</header>
+ <div id="pair-vision-read" class="hidden"></div>
<div id="pair-body"></div>
</div>
@@ -1081,7 +1089,27 @@ ${HAMBURGER_JS}
// ── DESIGN DETAIL
app.get('/design/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
- const design = DESIGNS.find(d => d.id === id);
+ let design = DESIGNS.find(d => d.id === id);
+ // Hot-path fallback: design was generated after server start (e.g. via chat).
+ // Look it up live in PG + lazily extend the in-memory snapshot.
+ if (!design) {
+ try {
+ const row = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category, dominant_hex, prompt, seed, local_path, is_published, created_at FROM spoon_all_designs WHERE id=${id}) t;`);
+ if (row) {
+ const r = JSON.parse(row);
+ const filename = (r.local_path || '').split('/').pop();
+ design = {
+ id: r.id, kind: r.kind, category: r.category, dominant_hex: r.dominant_hex,
+ saturation: 0, title: (r.prompt || '').split(',')[0].slice(0, 60) || `Design #${r.id}`,
+ handle: `wallco-${String(r.id).padStart(4,'0')}`,
+ image_url: `/designs/img/${filename}`, filename,
+ generator: 'Replicate SDXL', seed: r.seed,
+ is_published: !!r.is_published, created_at: r.created_at
+ };
+ DESIGNS.push(design);
+ }
+ } catch {}
+ }
if (!design) return res.status(404).type('html').send(`${htmlHead({title:'Not Found — wallco.ai',description:'',canonical:`https://wallco.ai/design/${id}`})}<body>${htmlHeader('')}<main><div class="empty-state" style="padding:80px;text-align:center"><h2>Design not found</h2><a href="/designs">Browse all designs</a></div></main>${FOOTER}${HAMBURGER_JS}</body></html>`);
const relIdx = DESIGNS.findIndex(d => d.id === id);
diff --git a/src/review.js b/src/review.js
index 1d3e81f..9101b27 100644
--- a/src/review.js
+++ b/src/review.js
@@ -19,9 +19,13 @@
const fs = require('fs');
const path = require('path');
-const REVIEWS_FILE = path.join(__dirname, '..', 'data', 'reviews.json');
-const CHATS_FILE = path.join(__dirname, '..', 'data', 'chip-chats.json');
-const PAIRINGS_FILE = path.join(__dirname, '..', 'data', 'pairings.json');
+const REVIEWS_FILE = path.join(__dirname, '..', 'data', 'reviews.json');
+const CHATS_FILE = path.join(__dirname, '..', 'data', 'chip-chats.json');
+const PAIRINGS_FILE = path.join(__dirname, '..', 'data', 'pairings.json');
+const MOODBOARD_FILE = path.join(__dirname, '..', 'data', 'moodboards.json');
+const VISION_FILE = path.join(__dirname, '..', 'data', 'vision-cache.json');
+const IMG_DIR = path.join(__dirname, '..', 'data', 'generated');
+const VISION_MODEL = process.env.OLLAMA_VISION_MODEL || 'llava:latest';
const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
const OLLAMA_MODEL = process.env.OLLAMA_REVIEW_MODEL || 'qwen3:14b';
@@ -200,25 +204,72 @@ function colorSwatches(hex) {
];
}
-// material/object suggestions via Ollama (catalog-agnostic, prompt-grounded)
+// Resolve the on-disk image path for a design (handles image_url + local_path variants).
+function getImagePath(design) {
+ if (design.local_path && fs.existsSync(design.local_path)) return design.local_path;
+ if (design.image_url) {
+ const filename = path.basename(design.image_url);
+ const p = path.join(IMG_DIR, filename);
+ if (fs.existsSync(p)) return p;
+ }
+ if (design.filename) {
+ const p = path.join(IMG_DIR, design.filename);
+ if (fs.existsSync(p)) return p;
+ }
+ return null;
+}
+
+// LLaVA vision read — describes the image in 1-2 sentences. Cached per design.
+async function visionDescribe(design) {
+ const cache = loadJSON(VISION_FILE, {});
+ if (cache[design.id]) return cache[design.id];
+ const imgPath = getImagePath(design);
+ if (!imgPath) return null;
+ try {
+ const b64 = fs.readFileSync(imgPath).toString('base64');
+ const res = await fetch(`${OLLAMA}/api/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: VISION_MODEL,
+ prompt: 'You are a senior interior designer looking at a wallcovering swatch. In one tight sentence (≤30 words), describe: motif/pattern type, dominant colors, scale (small/medium/large repeat), and overall mood (formal/casual/maximalist/restrained). No preamble.',
+ images: [b64],
+ stream: false,
+ options: { temperature: 0.3 }
+ }),
+ signal: AbortSignal.timeout(45_000)
+ });
+ if (!res.ok) throw new Error(`llava ${res.status}`);
+ const j = await res.json();
+ const desc = (j.response || '').trim().split('\n')[0].slice(0, 280);
+ if (desc) { cache[design.id] = desc; saveJSON(VISION_FILE, cache); }
+ return desc;
+ } catch (e) {
+ return null;
+ }
+}
+
+// material/object suggestions via Ollama — now vision-grounded if llava available.
async function materialSuggestions(design) {
+ const visionDesc = await visionDescribe(design);
const sys = `You are a senior interior designer. Suggest exactly 2 specific material or finish pairings for a wallcovering with these properties:
title: "${design.title || '(untitled)'}"
category: ${design.category || 'unknown'}
dominant hex: ${design.dominant_hex || 'unknown'}
kind: ${design.kind || 'seamless tile'}
-Respond as JSON only, no preamble: [{"label":"specific material/finish (≤5 words)","reason":"why it pairs (≤16 words)"},{...}]. Be concrete (e.g. "Aged brass hardware", "Walnut wood paneling", "Unbleached linen drapery"). Never generic ("nice furniture").`;
+${visionDesc ? `visual read: ${visionDesc}` : ''}
+Respond as JSON only, no preamble: [{"label":"specific material/finish (≤5 words)","reason":"why it pairs (≤16 words)"},{...}]. Be concrete (e.g. "Aged brass hardware", "Walnut wood paneling", "Unbleached linen drapery"). Never generic ("nice furniture"). Tie reasoning to what you actually see in the visual read.`;
try {
const raw = await ollamaCall({ system: sys, history: [], message: 'Give me the 2 pairings as a JSON array.' });
- // extract first [...] block
const m = raw.match(/\[[\s\S]*?\]/);
if (!m) return [];
const arr = JSON.parse(m[0]);
return arr.slice(0, 2).map(x => ({
type: 'material',
label: String(x.label || '').slice(0, 60),
- reason: String(x.reason || '').slice(0, 120),
+ reason: String(x.reason || '').slice(0, 140),
icon: '✦',
+ vision_grounded: !!visionDesc,
})).filter(x => x.label);
} catch (e) {
return [
@@ -234,19 +285,40 @@ async function generatePairings(design, allDesigns) {
Promise.resolve(colorSwatches(design.dominant_hex)),
materialSuggestions(design),
]);
+ // load existing moodboard for this design so pins survive cache regen
+ const moodboards = loadJSON(MOODBOARD_FILE, {});
+ const pinnedKeys = new Set((moodboards[design.id] || []).map(p => p.pin_key));
+ const allSuggestions = [...matches, ...colors, ...materials].map((s, i) => ({
+ ...s,
+ idx: i,
+ pin_key: pinKey(s, design.id),
+ pinned: pinnedKeys.has(pinKey(s, design.id)),
+ }));
+ // get cached vision read for header display
+ const visionCache = loadJSON(VISION_FILE, {});
return {
design_id: design.id,
+ vision_description: visionCache[design.id] || null,
generated_at: new Date().toISOString(),
- suggestions: [...matches, ...colors, ...materials],
+ suggestions: allSuggestions,
};
}
+// Stable identifier for a suggestion so pins survive regenerations.
+function pinKey(s, designId) {
+ if (s.type === 'design') return `d:${s.design_id}`;
+ if (s.type === 'color') return `c:${(s.hex || '').toLowerCase()}`;
+ return `m:${(s.label || '').toLowerCase().replace(/[^a-z0-9]+/g,'-')}`;
+}
+
// ── mount ─────────────────────────────────────────────────────────────
function mount(app, getDesigns) {
// ensure files exist
if (!fs.existsSync(REVIEWS_FILE)) saveJSON(REVIEWS_FILE, {});
if (!fs.existsSync(CHATS_FILE)) saveJSON(CHATS_FILE, {});
if (!fs.existsSync(PAIRINGS_FILE)) saveJSON(PAIRINGS_FILE, {});
+ if (!fs.existsSync(MOODBOARD_FILE)) saveJSON(MOODBOARD_FILE, {});
+ if (!fs.existsSync(VISION_FILE)) saveJSON(VISION_FILE, {});
app.get('/api/review/:id', (req, res) => {
const reviews = loadJSON(REVIEWS_FILE, {});
@@ -342,12 +414,14 @@ function mount(app, getDesigns) {
res.json(pairings);
});
- // POST refresh — invalidate cache
+ // POST refresh — invalidate cache (both pairings + vision)
app.post('/api/pairings/:id/refresh', async (req, res) => {
const id = req.params.id;
const designs = getDesigns();
const design = designs.find(d => String(d.id) === String(id));
if (!design) return res.status(404).json({ error: 'design not found' });
+ // bust vision cache too
+ const vc = loadJSON(VISION_FILE, {}); delete vc[id]; saveJSON(VISION_FILE, vc);
const pairings = await generatePairings(design, designs);
const cache = loadJSON(PAIRINGS_FILE, {});
cache[id] = pairings;
@@ -355,7 +429,112 @@ function mount(app, getDesigns) {
res.json(pairings);
});
- console.log(' Review layer mounted (sliders + chips + chat + pairings on Ollama ' + OLLAMA_MODEL + ')');
+ // Moodboard: pin/unpin a suggestion.
+ app.post('/api/moodboard/:id/pin', (req, res) => {
+ const id = req.params.id;
+ const { pin_key, suggestion } = req.body || {};
+ if (!pin_key) return res.status(400).json({ error: 'pin_key required' });
+ const moodboards = loadJSON(MOODBOARD_FILE, {});
+ moodboards[id] = moodboards[id] || [];
+ if (moodboards[id].some(p => p.pin_key === pin_key)) {
+ // unpin (toggle)
+ moodboards[id] = moodboards[id].filter(p => p.pin_key !== pin_key);
+ } else {
+ // pin — store the whole suggestion
+ moodboards[id].push({ pin_key, ...suggestion, pinned_at: new Date().toISOString() });
+ }
+ saveJSON(MOODBOARD_FILE, moodboards);
+ // sync the pairings cache `pinned` flag so UI is consistent
+ const cache = loadJSON(PAIRINGS_FILE, {});
+ if (cache[id] && cache[id].suggestions) {
+ const pinnedKeys = new Set(moodboards[id].map(p => p.pin_key));
+ cache[id].suggestions = cache[id].suggestions.map(s => ({ ...s, pinned: pinnedKeys.has(s.pin_key) }));
+ saveJSON(PAIRINGS_FILE, cache);
+ }
+ res.json({ moodboard: moodboards[id] });
+ });
+
+ // GET moodboard for one design
+ app.get('/api/moodboard/:id', (req, res) => {
+ const moodboards = loadJSON(MOODBOARD_FILE, {});
+ res.json({ design_id: req.params.id, items: moodboards[req.params.id] || [] });
+ });
+
+ // GET all moodboards (admin)
+ app.get('/api/moodboards/all', (req, res) => {
+ res.json(loadJSON(MOODBOARD_FILE, {}));
+ });
+
+ // CSV export — kept designs + scores + verdict + pinned pairings
+ app.get('/api/export/keep.csv', (req, res) => {
+ const reviews = loadJSON(REVIEWS_FILE, {});
+ const moodboards = loadJSON(MOODBOARD_FILE, {});
+ const designs = getDesigns();
+ const cols = ['design_id', 'title', 'category', 'kind', 'dominant_hex', 'score_design', 'score_color', 'score_style', 'verdict', 'why', 'image_url', 'pinned_count', 'pinned_summary', 'reviewed_at'];
+ const rows = [cols.join(',')];
+ for (const [id, r] of Object.entries(reviews)) {
+ if (r.decision !== 'keep') continue;
+ const d = designs.find(x => String(x.id) === String(id)) || {};
+ const pinned = moodboards[id] || [];
+ const pinnedSummary = pinned.map(p => (p.label || p.hex || '').replace(/"/g,'""').slice(0, 40)).join(' · ');
+ const row = [
+ id,
+ csvCell(d.title || d.prompt || ''),
+ csvCell(d.category || ''),
+ csvCell(d.kind || ''),
+ csvCell(d.dominant_hex || ''),
+ r.design ?? '',
+ r.color ?? '',
+ r.style ?? '',
+ r.decision || '',
+ csvCell(r.why || ''),
+ csvCell(d.image_url || (d.local_path ? '/img/' + path.basename(d.local_path) : '')),
+ pinned.length,
+ csvCell(pinnedSummary),
+ r.updated_at || '',
+ ];
+ rows.push(row.join(','));
+ }
+ res.type('text/csv').setHeader('Content-Disposition', 'attachment; filename="wallco-keep-' + new Date().toISOString().slice(0,10) + '.csv"');
+ res.send(rows.join('\n') + '\n');
+ });
+
+ // CSV export — all reviewed designs (any decision)
+ app.get('/api/export/all.csv', (req, res) => {
+ const reviews = loadJSON(REVIEWS_FILE, {});
+ const moodboards = loadJSON(MOODBOARD_FILE, {});
+ const designs = getDesigns();
+ const cols = ['design_id', 'title', 'category', 'kind', 'dominant_hex', 'score_design', 'score_color', 'score_style', 'verdict', 'why', 'image_url', 'pinned_count', 'pinned_summary', 'reviewed_at'];
+ const rows = [cols.join(',')];
+ for (const [id, r] of Object.entries(reviews)) {
+ const d = designs.find(x => String(x.id) === String(id)) || {};
+ const pinned = moodboards[id] || [];
+ const pinnedSummary = pinned.map(p => (p.label || p.hex || '').replace(/"/g,'""').slice(0, 40)).join(' · ');
+ rows.push([
+ id, csvCell(d.title || d.prompt || ''), csvCell(d.category || ''), csvCell(d.kind || ''),
+ csvCell(d.dominant_hex || ''),
+ r.design ?? '', r.color ?? '', r.style ?? '',
+ r.decision || '',
+ csvCell(r.why || ''),
+ csvCell(d.image_url || ''),
+ pinned.length,
+ csvCell(pinnedSummary),
+ r.updated_at || '',
+ ].join(','));
+ }
+ res.type('text/csv').setHeader('Content-Disposition', 'attachment; filename="wallco-all-' + new Date().toISOString().slice(0,10) + '.csv"');
+ res.send(rows.join('\n') + '\n');
+ });
+
+ console.log(' Review layer mounted (sliders + chips + chat + pairings + moodboard + CSV export on Ollama ' + OLLAMA_MODEL + ' / ' + VISION_MODEL + ')');
+}
+
+function csvCell(s) {
+ s = String(s == null ? '' : s);
+ if (s.includes(',') || s.includes('"') || s.includes('\n')) {
+ return '"' + s.replace(/"/g, '""') + '"';
+ }
+ return s;
}
function clamp(n, min, max) {
← 98dbbba Add /designs pairing suggestions — Pair button + side modal
·
back to Wallco Ai
·
Pairings v2: LLaVA vision read (Mac2 localhost:11434) drives a3ace82 →