[object Object]

← 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

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, '&quot;').replace(/</g,'&lt;'); }
 
   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 →