← back to Wallco Ai
Pairings v2: LLaVA vision read (Mac2 localhost:11434) drives material suggestions, moodboard pin/unpin per suggestion, CSV export of kept designs + pinned pairings
a3ace82dde254392b209dfaa4d394e85fd5fb31a · 2026-05-11 17:47:35 -0700 · Steve Abrams
Files touched
M data/moodboards.jsonM data/pairings.jsonM data/vision-cache.jsonM server.jsM src/review.js
Diff
commit a3ace82dde254392b209dfaa4d394e85fd5fb31a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 11 17:47:35 2026 -0700
Pairings v2: LLaVA vision read (Mac2 localhost:11434) drives material suggestions, moodboard pin/unpin per suggestion, CSV export of kept designs + pinned pairings
---
data/moodboards.json | 13 ++++++++++-
data/pairings.json | 43 +++++++++++++++++++++++++++----------
data/vision-cache.json | 4 +++-
server.js | 58 +++++++++++++++++++++++++++++++++++++++++++-------
src/review.js | 6 ++++--
5 files changed, 101 insertions(+), 23 deletions(-)
diff --git a/data/moodboards.json b/data/moodboards.json
index 9e26dfe..477cb5a 100644
--- a/data/moodboards.json
+++ b/data/moodboards.json
@@ -1 +1,12 @@
-{}
\ No newline at end of file
+{
+ "11": [
+ {
+ "pin_key": "c:#5d73b1",
+ "type": "color",
+ "hex": "#5d73b1",
+ "label": "test",
+ "reason": "test",
+ "pinned_at": "2026-05-12T00:43:50.526Z"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/data/pairings.json b/data/pairings.json
index 129ceaa..e2379dd 100644
--- a/data/pairings.json
+++ b/data/pairings.json
@@ -1,7 +1,8 @@
{
"11": {
"design_id": 11,
- "generated_at": "2026-05-12T00:35:12.508Z",
+ "vision_description": "Floral pattern with pink peonies on a light background; large scale repeat; formal, elegant, and luxurious mood.",
+ "generated_at": "2026-05-12T00:47:19.449Z",
"suggestions": [
{
"type": "design",
@@ -9,7 +10,10 @@
"label": "Sapphire Floret No.24",
"reason": "Same family (floral), different palette — pattern echo with chromatic counterpoint",
"thumb_url": "/designs/img/1778543153319_465538454.png",
- "hex": "#0b1730"
+ "hex": "#0b1730",
+ "idx": 0,
+ "pin_key": "d:24",
+ "pinned": false
},
{
"type": "design",
@@ -17,31 +21,48 @@
"label": "Honey Studio No.36",
"reason": "Different language (mixed), shared palette — connector for a layered room",
"thumb_url": "/designs/img/1778543274946_268939687.png",
- "hex": "#8f8b61"
+ "hex": "#8f8b61",
+ "idx": 1,
+ "pin_key": "d:36",
+ "pinned": false
},
{
"type": "color",
"hex": "#5d73b1",
"label": "Complement · #5d73b1",
- "reason": "Direct chromatic opposite — trim, hardware, or a single accent piece"
+ "reason": "Direct chromatic opposite — trim, hardware, or a single accent piece",
+ "idx": 2,
+ "pin_key": "c:#5d73b1",
+ "pinned": true
},
{
"type": "color",
"hex": "#9db15d",
"label": "Analogous · #9db15d",
- "reason": "Neighboring hue — supporting fabric or upholstery, harmonious not loud"
+ "reason": "Neighboring hue — supporting fabric or upholstery, harmonious not loud",
+ "idx": 3,
+ "pin_key": "c:#9db15d",
+ "pinned": false
},
{
"type": "material",
- "label": "Walnut wood paneling",
- "reason": "Warm wood tones echo the honey hues, adding depth without overpowering the floral pattern.",
- "icon": "✦"
+ "label": "Aged brass hardware",
+ "reason": "Warm metallics echo pink peonies and add formal luxury without overpowering the delicate floral motif.",
+ "icon": "✦",
+ "vision_grounded": true,
+ "idx": 4,
+ "pin_key": "m:aged-brass-hardware",
+ "pinned": false
},
{
"type": "material",
- "label": "Aged brass hardware",
- "reason": "Subtle metallic warmth complements the earthy base, enhancing the bouquet's richness with soft contrast.",
- "icon": "✦"
+ "label": "Walnut wood paneling",
+ "reason": "Rich, textured wood contrasts with light background, enhancing depth and sophistication of the elegant floral design.",
+ "icon": "✦",
+ "vision_grounded": true,
+ "idx": 5,
+ "pin_key": "m:walnut-wood-paneling",
+ "pinned": false
}
]
}
diff --git a/data/vision-cache.json b/data/vision-cache.json
index 9e26dfe..4e7091e 100644
--- a/data/vision-cache.json
+++ b/data/vision-cache.json
@@ -1 +1,3 @@
-{}
\ No newline at end of file
+{
+ "11": "Floral pattern with pink peonies on a light background; large scale repeat; formal, elegant, and luxurious mood."
+}
\ No newline at end of file
diff --git a/server.js b/server.js
index 75174de..459500b 100644
--- a/server.js
+++ b/server.js
@@ -695,6 +695,11 @@ ${htmlHeader('/designs')}
</div>
</form>
<p class="result-count">${total} design${total===1?'':'s'}${cat?' in '+cat:''}${q?' matching "'+q+'"':''}</p>
+ ${isAdmin ? `<div class="export-links">
+ <span>Export:</span>
+ <a href="/api/export/keep.csv" download>Kept (CSV)</a>
+ <a href="/api/export/all.csv" download>All reviewed (CSV)</a>
+ </div>` : ''}
</div>
<div class="design-grid catalog-grid" id="catalog-grid">
@@ -1036,46 +1041,83 @@ ${isAdmin ? `
CURRENT_PAIR_ID = designId; CURRENT_PAIR_TITLE = title;
$('#pair-design-title').textContent = 'Pairs well with this design';
$('#pair-design-sub').textContent = '#' + designId + ' · ' + title.slice(0,80);
- pairBody.innerHTML = '<div class="pair-loading">' + (forceRefresh ? 'regenerating…' : 'loading…') + '</div>';
+ $('#pair-vision-read').classList.add('hidden');
+ $('#pair-vision-read').textContent = '';
+ pairBody.innerHTML = '<div class="pair-loading">' + (forceRefresh ? 'regenerating (vision + Ollama, ~10s)…' : 'loading…') + '</div>';
pairModal.classList.add('open');
try {
const url = '/api/pairings/' + designId + (forceRefresh ? '/refresh' : '');
const r = await fetch(url, { method: forceRefresh ? 'POST' : 'GET' });
const j = await r.json();
+ if (j.vision_description) {
+ const vr = $('#pair-vision-read');
+ vr.textContent = '👁 ' + j.vision_description;
+ vr.classList.remove('hidden');
+ }
renderPairings(j);
} catch (e) {
pairBody.innerHTML = '<div class="pair-empty">error loading: ' + e.message + '</div>';
}
}
+ async function togglePin(designId, suggestion, cardEl) {
+ cardEl.classList.toggle('pinned');
+ try {
+ await fetch('/api/moodboard/' + designId + '/pin', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ pin_key: suggestion.pin_key, suggestion })
+ });
+ } catch (e) {
+ cardEl.classList.toggle('pinned'); // revert on error
+ }
+ }
+
function renderPairings(data) {
if (!data.suggestions || !data.suggestions.length) {
pairBody.innerHTML = '<div class="pair-empty">No suggestions available.</div>';
return;
}
pairBody.innerHTML = data.suggestions.map(s => {
+ const pinned = s.pinned ? ' pinned' : '';
+ const pinTitle = s.pinned ? 'Unpin from moodboard' : 'Pin to moodboard';
+ const visionBadge = s.vision_grounded ? '<span class="pair-vision-badge" title="Grounded in LLaVA visual read">👁 vision</span>' : '';
+ const pinBtn = '<button class="pair-pin" type="button" data-pin-key="' + escapeAttr(s.pin_key) + '" title="' + pinTitle + '">★</button>';
if (s.type === 'design') {
const thumb = s.thumb_url
? '<div class="pair-thumb" style="background-image:url(\\''+s.thumb_url+'\\')"></div>'
: '<div class="pair-thumb material">◧</div>';
- return '<div class="pair-card"><a class="pair-link" href="/design/' + s.design_id + '" target="_blank">' + thumb +
+ return '<div class="pair-card' + pinned + '" data-pin-key="' + escapeAttr(s.pin_key) + '">' +
+ '<a class="pair-link" href="/design/' + s.design_id + '" target="_blank">' + thumb +
'<div class="pair-body"><span class="pair-type">design · #' + s.design_id + '</span>' +
'<div class="pair-title">' + escapeHTML(s.label) + (s.hex ? '<span class="pair-hexcode">'+s.hex+'</span>' : '') + '</div>' +
- '<div class="pair-reason">' + escapeHTML(s.reason) + '</div></div></a></div>';
+ '<div class="pair-reason">' + escapeHTML(s.reason) + '</div></div></a>' + pinBtn + '</div>';
}
if (s.type === 'color') {
- return '<div class="pair-card"><div class="pair-thumb" style="background:' + (s.hex||'#888') + '"></div>' +
+ return '<div class="pair-card' + pinned + '" data-pin-key="' + escapeAttr(s.pin_key) + '">' +
+ '<div class="pair-thumb" style="background:' + (s.hex||'#888') + '"></div>' +
'<div class="pair-body"><span class="pair-type">color swatch</span>' +
'<div class="pair-title">' + escapeHTML(s.label) + '</div>' +
- '<div class="pair-reason">' + escapeHTML(s.reason) + '</div></div></div>';
+ '<div class="pair-reason">' + escapeHTML(s.reason) + '</div></div>' + pinBtn + '</div>';
}
// material / object
- return '<div class="pair-card"><div class="pair-thumb material">' + (s.icon || '✦') + '</div>' +
- '<div class="pair-body"><span class="pair-type">material</span>' +
+ return '<div class="pair-card' + pinned + '" data-pin-key="' + escapeAttr(s.pin_key) + '">' +
+ '<div class="pair-thumb material">' + (s.icon || '✦') + '</div>' +
+ '<div class="pair-body"><span class="pair-type">material' + visionBadge + '</span>' +
'<div class="pair-title">' + escapeHTML(s.label) + '</div>' +
- '<div class="pair-reason">' + escapeHTML(s.reason) + '</div></div></div>';
+ '<div class="pair-reason">' + escapeHTML(s.reason) + '</div></div>' + pinBtn + '</div>';
}).join('') + '<div style="text-align:right; font-size:10px; color:#666; margin-top:6px">generated ' + (data.generated_at ? new Date(data.generated_at).toLocaleString() : '—') + '</div>';
+ // wire pin buttons
+ pairBody.querySelectorAll('.pair-pin').forEach(btn => {
+ btn.addEventListener('click', e => {
+ e.preventDefault(); e.stopPropagation();
+ const pinKey = btn.dataset.pinKey;
+ const suggestion = data.suggestions.find(s => s.pin_key === pinKey);
+ const card = btn.closest('.pair-card');
+ if (suggestion && card) togglePin(CURRENT_PAIR_ID, suggestion, card);
+ });
+ });
}
+ function escapeAttr(s) { return String(s||'').replace(/"/g, '"').replace(/</g,'<'); }
boot();
})();
diff --git a/src/review.js b/src/review.js
index 9101b27..3a2ff7c 100644
--- a/src/review.js
+++ b/src/review.js
@@ -26,6 +26,8 @@ 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';
+// Vision models live on Mac2 (local) — default qwen3 chat runs on Mac1 (192.168.1.133).
+const OLLAMA_VISION_URL = process.env.OLLAMA_VISION_URL || 'http://localhost:11434';
const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
const OLLAMA_MODEL = process.env.OLLAMA_REVIEW_MODEL || 'qwen3:14b';
@@ -227,7 +229,7 @@ async function visionDescribe(design) {
if (!imgPath) return null;
try {
const b64 = fs.readFileSync(imgPath).toString('base64');
- const res = await fetch(`${OLLAMA}/api/generate`, {
+ const res = await fetch(`${OLLAMA_VISION_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -237,7 +239,7 @@ async function visionDescribe(design) {
stream: false,
options: { temperature: 0.3 }
}),
- signal: AbortSignal.timeout(45_000)
+ signal: AbortSignal.timeout(90_000)
});
if (!res.ok) throw new Error(`llava ${res.status}`);
const j = await res.json();
← 2922a59 wallco.ai: hot-path fallback for /design/:id (live PG lookup
·
back to Wallco Ai
·
studio: textures + drag-drop + palette suggestions + designe 679232f →