← back to Marketing Command Center
assets: local qwen2.5vl vision enrichment (aiTags) + search
e8501e840340b19173d043c3c91e58dc7ba2a5b7 · 2026-07-21 14:36:04 -0700 · Steve
Files touched
M modules/assets/index.js
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 →