[object Object]

← back to Marketing Command Center

assets: local qwen2.5vl vision enrichment (aiTags) + search

e8501e840340b19173d043c3c91e58dc7ba2a5b7 · 2026-07-21 14:36:04 -0700 · Steve

Files touched

Diff

commit e8501e840340b19173d043c3c91e58dc7ba2a5b7
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 21 14:36:04 2026 -0700

    assets: local qwen2.5vl vision enrichment (aiTags) + search
---
 modules/assets/index.js | 178 +++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 175 insertions(+), 3 deletions(-)

diff --git a/modules/assets/index.js b/modules/assets/index.js
index 528968f..6f8683b 100644
--- a/modules/assets/index.js
+++ b/modules/assets/index.js
@@ -46,6 +46,127 @@ const MIME_EXT = {
 };
 const MAX_BYTES = 20 * 1024 * 1024; // 20MB per file
 
+// ── local vision auto-tagging (qwen2.5vl via Ollama, $0) ─────────────────────
+// Enriches an asset with AI tags from a LOCAL vision model so the library is
+// searchable by colors/style/room/mood/subjects. Primary = Mac2 Ollama, with a
+// one-shot fallback to the LAN box. Never throws — returns null on any failure
+// so the caller leaves the asset untouched.
+const OLLAMA_PRIMARY  = (process.env.OLLAMA_PRIMARY  || 'http://127.0.0.1:11434').replace(/\/+$/, '');
+const OLLAMA_FALLBACK = (process.env.OLLAMA_FALLBACK || 'http://192.168.1.133:11434').replace(/\/+$/, '');
+const VISION_MODEL    = process.env.ENGINE_VISION_MODEL || 'qwen2.5vl:7b';
+const VISION_PROMPT   = 'You tag interior-design product images for a wallcovering retailer. Return strict JSON: {"colors":["…"],"style":"…","room":"…","mood":"…","subjects":["…"]}. Concise values.';
+const REMOTE_IMG_CAP  = 10 * 1024 * 1024; // 10MB cap when fetching a URL asset
+
+// Parse the model's message.content as JSON, defensively (strip ```json fences).
+function parseVisionJson(content) {
+  if (!content) return null;
+  let s = String(content).trim();
+  const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
+  if (fence) s = fence[1].trim();
+  else { const b = s.indexOf('{'), e = s.lastIndexOf('}'); if (b !== -1 && e > b) s = s.slice(b, e + 1); }
+  try { return JSON.parse(s); } catch { return null; }
+}
+
+// One POST to an Ollama /api/chat endpoint with a 60s abort timeout.
+async function ollamaVision(base, base64) {
+  const ctl = new AbortController();
+  const t = setTimeout(() => ctl.abort(), 60000);
+  try {
+    const r = await fetch(`${base}/api/chat`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      signal: ctl.signal,
+      body: JSON.stringify({
+        model: VISION_MODEL, stream: false, format: 'json',
+        messages: [{ role: 'user', content: VISION_PROMPT, images: [base64] }],
+      }),
+    });
+    if (!r.ok) throw new Error(`ollama ${r.status}`);
+    const d = await r.json();
+    return parseVisionJson(d && d.message && d.message.content);
+  } finally { clearTimeout(t); }
+}
+
+// Accepts a base64 string or a Buffer. Tries the primary endpoint, then falls
+// back once to the LAN box. Returns the parsed tags object or null.
+async function visionTags(imageBase64OrBuffer) {
+  const base64 = Buffer.isBuffer(imageBase64OrBuffer)
+    ? imageBase64OrBuffer.toString('base64')
+    : String(imageBase64OrBuffer || '').replace(/^data:[^;,]+;base64,/, '');
+  if (!base64) return null;
+  try { const t = await ollamaVision(OLLAMA_PRIMARY, base64); if (t) return t; }
+  catch { /* fall through to the LAN box */ }
+  try { const t = await ollamaVision(OLLAMA_FALLBACK, base64); if (t) return t; }
+  catch { /* both down → caller leaves the asset untouched */ }
+  return null;
+}
+
+// Resolve an asset's image bytes as base64: a locally-stored binary is read from
+// disk; a remote-URL asset is fetched (10MB cap, 30s timeout). Returns null if
+// the bytes can't be obtained.
+async function assetBase64(asset) {
+  if (asset.filename) {
+    try { return fs.readFileSync(path.join(FILES_DIR, asset.filename)).toString('base64'); }
+    catch { return null; }
+  }
+  if (asset.url && /^https?:\/\//i.test(asset.url)) {
+    const ctl = new AbortController();
+    const t = setTimeout(() => ctl.abort(), 30000);
+    try {
+      const r = await fetch(asset.url, { signal: ctl.signal, redirect: 'follow' });
+      if (!r.ok) return null;
+      const ab = await r.arrayBuffer();
+      const buf = Buffer.from(ab);
+      if (buf.length > REMOTE_IMG_CAP) return null;
+      return buf.toString('base64');
+    } catch { return null; }
+    finally { clearTimeout(t); }
+  }
+  return null;
+}
+
+// Enrich a single asset in place in the store and persist. Returns the tags on
+// success, null otherwise. Re-reads the store before writing so concurrent
+// adds aren't clobbered.
+async function enrichAssetById(id) {
+  const asset = readStore().find(a => a.id === id);
+  if (!asset) return null;
+  const b64 = await assetBase64(asset);
+  if (!b64) return null;
+  const tags = await visionTags(b64);
+  if (!tags) return null;
+  const store = readStore();
+  const cur = store.find(a => a.id === id);
+  if (!cur) return null;
+  cur.aiTags = Object.assign({}, tags, { at: new Date().toISOString(), model: VISION_MODEL });
+  writeStore(store);
+  return cur.aiTags;
+}
+
+// Fire-and-forget enrichment of one or more asset ids (swallow all errors) — used
+// after upload / catalog-pull so it never blocks or fails the original request.
+function enrichLater(ids) {
+  for (const id of [].concat(ids)) {
+    if (!id) continue;
+    setImmediate(() => { enrichAssetById(id).catch(() => {}); });
+  }
+}
+
+// Flatten an asset's aiTags values into a single searchable string.
+function aiTagsText(a) {
+  const t = a && a.aiTags; if (!t) return '';
+  const parts = [];
+  for (const k of ['colors', 'subjects', 'style', 'room', 'mood']) {
+    const v = t[k];
+    if (Array.isArray(v)) parts.push(v.join(' '));
+    else if (v) parts.push(String(v));
+  }
+  return parts.join(' ');
+}
+
+// Guard so two /enrich-all loops never run at once (vision inference is heavy).
+let _enrichAllRunning = false;
+
 // Real DW catalog images — baked seed so the library is never empty on a fresh box.
 const SEED = [
   { name: 'Swan River — Lake Scenic',        url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/products/d1272f61506f179f56aee4cb3fd37187.jpg' },
@@ -168,12 +289,61 @@ module.exports = {
     ensureDirs();
     seedIfEmpty();
 
-    // List — newest first.
-    router.get('/list', (_req, res) => {
-      const store = readStore().slice().sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
+    // List — newest first. Optional ?q= case-insensitively matches the asset
+    // name/title, its existing tags, and any aiTags values (colors/style/room/
+    // mood/subjects, flattened).
+    router.get('/list', (req, res) => {
+      let store = readStore().slice().sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
+      const q = (req.query.q || '').toString().trim().toLowerCase();
+      if (q) {
+        store = store.filter(a => {
+          const hay = [a.name, a.title, (a.tags || []).join(' '), aiTagsText(a)].join(' ').toLowerCase();
+          return hay.includes(q);
+        });
+      }
       res.json({ assets: store.map(withSrc) });
     });
 
+    // Enrich a single asset with local-vision AI tags. On success merges
+    // aiTags and persists; leaves the asset untouched (and reports the reason)
+    // if the model or image bytes are unavailable.
+    router.post('/enrich/:id', async (req, res) => {
+      const asset = readStore().find(a => a.id === req.params.id);
+      if (!asset) return res.status(404).json({ ok: false, error: 'not found' });
+      const b64 = await assetBase64(asset);
+      if (!b64) return res.status(422).json({ ok: false, error: 'could not obtain image bytes' });
+      let aiTags;
+      try { aiTags = await visionTags(b64); }
+      catch (e) { return res.status(502).json({ ok: false, error: e.message }); }
+      if (!aiTags) return res.status(502).json({ ok: false, error: 'vision model unavailable' });
+      const store = readStore();
+      const cur = store.find(a => a.id === req.params.id);
+      if (!cur) return res.status(404).json({ ok: false, error: 'not found' });
+      cur.aiTags = Object.assign({}, aiTags, { at: new Date().toISOString(), model: VISION_MODEL });
+      writeStore(store);
+      res.json({ ok: true, aiTags: cur.aiTags });
+    });
+
+    // Background enrich-all: fire-and-forget loop over assets missing aiTags,
+    // strictly one-at-a-time (vision inference is heavy) with a 500ms pause
+    // between items. Responds immediately; a module-level flag prevents two
+    // concurrent loops.
+    router.post('/enrich-all', (_req, res) => {
+      if (_enrichAllRunning) return res.json({ ok: true, queued: 0, note: 'an enrich-all run is already in progress' });
+      const todo = readStore().filter(a => !a.aiTags).map(a => a.id);
+      if (!todo.length) return res.json({ ok: true, queued: 0 });
+      _enrichAllRunning = true;
+      (async () => {
+        try {
+          for (const id of todo) {
+            try { await enrichAssetById(id); } catch { /* skip, keep going */ }
+            await new Promise(r => setTimeout(r, 500));
+          }
+        } finally { _enrichAllRunning = false; }
+      })();
+      res.json({ ok: true, queued: todo.length });
+    });
+
     // Upload a file as base64 data URL. Body: { name, dataUrl }.
     // (Global JSON limit is raised in server.js to accommodate image payloads.)
     router.post('/upload', express.json({ limit: '28mb' }), (req, res) => {
@@ -199,6 +369,7 @@ module.exports = {
         filename, mime, size: buf.length, tags: ['upload'], created_at: new Date().toISOString(),
       };
       const store = readStore(); store.push(asset); writeStore(store);
+      enrichLater(asset.id); // fire-and-forget local-vision tagging
       res.json({ asset: withSrc(asset) });
     });
 
@@ -256,6 +427,7 @@ module.exports = {
         size: 0, tags: ['dw-catalog'], created_at: new Date().toISOString(),
       };
       store.push(asset); writeStore(store);
+      enrichLater(asset.id); // fire-and-forget local-vision tagging
       res.json({ asset: withSrc(asset) });
     });
 

← 70479a4 youtube: add force-ssl scope for videos.update (visibility c  ·  back to Marketing Command Center  ·  engine: core module (store/config/skeleton) + channels.postL 0938d51 →