[object Object]

← back to Wallco Ai

admin/inspiration: two-step flow + Export decisions.json

d12476dbc4e19803543228fb2232a87fa697af6d · 2026-05-19 14:53:07 -0700 · SteveStudio2

Splits Generate into Step 1 (LLM-only /prompts, returns 3 SDXL prompts with per-prompt settlement_warnings) and Step 2 (/generate now accepts body.prompts so an edited prompt can be rendered solo). Client renders editable textareas with warning banners + per-prompt Render button + result tile; every action lands in localStorage wallco.admin.decisions, downloadable as wallco-decisions-YYYY-MM-DD.json. Lets the admin edit settlement-tripping terms before burning a Replicate call.

Files touched

Diff

commit d12476dbc4e19803543228fb2232a87fa697af6d
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue May 19 14:53:07 2026 -0700

    admin/inspiration: two-step flow + Export decisions.json
    
    Splits Generate into Step 1 (LLM-only /prompts, returns 3 SDXL prompts with per-prompt settlement_warnings) and Step 2 (/generate now accepts body.prompts so an edited prompt can be rendered solo). Client renders editable textareas with warning banners + per-prompt Render button + result tile; every action lands in localStorage wallco.admin.decisions, downloadable as wallco-decisions-YYYY-MM-DD.json. Lets the admin edit settlement-tripping terms before burning a Replicate call.
---
 public/js/admin.js | 182 +++++++++++++++++++++++++++++++++++++++++------------
 src/admin.js       | 165 ++++++++++++++++++++++++++++--------------------
 2 files changed, 240 insertions(+), 107 deletions(-)

diff --git a/public/js/admin.js b/public/js/admin.js
index 1b22205..4311c48 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -48,46 +48,150 @@
     });
   });
 
-  // Generate-from-pool button
+  // Two-step Generate flow + Export decisions.json.
+  //   Step 1 — Click gen-btn → POST /api/admin/inspiration/prompts → 3 SDXL
+  //            prompts come back with per-prompt settlement_warnings. The
+  //            admin can edit each prompt in a textarea before rendering.
+  //   Step 2 — Click "Render →" on a card → POST /api/admin/inspiration/generate
+  //            with { prompts:[edited] } → just THAT prompt is screened +
+  //            sent to Replicate. The card swaps in either the new design
+  //            tile or a clear error (blocked term, network, etc.).
+  // Every action lands in localStorage `wallco.admin.decisions` so the admin
+  // has an audit trail of which prompts were edited, which rendered, and
+  // which were blocked. "Export decisions.json" downloads that ledger.
   const gen = document.getElementById('gen-btn');
-  if (gen) gen.addEventListener('click', async () => {
-    gen.disabled = true;
-    const orig = gen.textContent;
-    gen.textContent = 'Generating 3 designs… (~90s)';
-    try {
-      const r = await fetch('/api/admin/inspiration/generate', {
-        method: 'POST', headers: { 'Content-Type': 'application/json' },
-        credentials: 'include'
+  if (gen) {
+    const DEC_KEY = 'wallco.admin.decisions';
+    const loadDecisions = () => { try { return JSON.parse(localStorage.getItem(DEC_KEY) || '[]'); } catch { return []; } };
+    const saveDecision = (d) => {
+      const a = loadDecisions();
+      a.push({ ts: new Date().toISOString(), ...d });
+      localStorage.setItem(DEC_KEY, JSON.stringify(a));
+    };
+    const escHtml = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+    const origGenText = gen.textContent;
+
+    function refreshDecCount() {
+      const el = document.getElementById('dec-count');
+      if (el) el.textContent = loadDecisions().length;
+    }
+
+    function renderPromptTray(container, prompts, warnings) {
+      container.innerHTML =
+        '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">'
+        + '<h2 style="font:300 22px/1 var(--serif,Georgia);margin:0;color:var(--ink,#1a1208)">'
+        + prompts.length + ' prompt' + (prompts.length === 1 ? '' : 's') + ' — edit, then render</h2>'
+        + '<button id="dec-export" type="button" style="padding:6px 12px;border:1px solid var(--gold,#c69b3e);background:transparent;color:var(--gold,#c69b3e);border-radius:4px;font:600 11px var(--sans,system-ui);letter-spacing:.08em;text-transform:uppercase;cursor:pointer">'
+        + 'Export decisions.json (<span id="dec-count">' + loadDecisions().length + '</span>)</button>'
+        + '</div>'
+        + prompts.map(function (p, i) {
+            const w = warnings[i] || {};
+            const warnBanner = w.verdict === 'BLOCK'
+              ? '<div style="margin:8px 0;padding:8px 10px;background:#3a2419;border-left:3px solid #c46a3a;color:#e6b896;font:12px var(--sans,system-ui);border-radius:0 4px 4px 0">⚖ Contains "<b>' + escHtml(w.term) + '</b>" — the settlement gate will BLOCK this. Edit it out, then render.</div>'
+              : '<div style="margin:4px 0 8px;font:11px var(--sans,system-ui);color:#7fc77f;letter-spacing:.05em">✓ settlement-safe</div>';
+            return ''
+              + '<div class="dec-card" data-i="' + i + '" data-original="' + escHtml(p) + '"'
+              + ' style="margin-bottom:16px;padding:14px;background:var(--card-bg,#1a140c);border:1px solid var(--card-border,#3a2f1a);border-radius:6px">'
+              + '<div style="font:600 11px var(--sans,system-ui);letter-spacing:.1em;text-transform:uppercase;color:var(--ink-muted,#8a7960);margin-bottom:6px">Prompt ' + (i + 1) + '</div>'
+              + warnBanner
+              + '<textarea class="dec-prompt" rows="4" style="width:100%;min-height:80px;padding:9px 11px;background:#0f0c08;color:var(--ink,#f4ece0);border:1px solid var(--card-border,#3a2f1a);border-radius:4px;font:13px/1.5 var(--sans,system-ui);box-sizing:border-box;resize:vertical">' + escHtml(p) + '</textarea>'
+              + '<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px">'
+              +   '<span style="font:11px var(--sans,system-ui);color:var(--ink-muted,#8a7960)">≈ $0.003 to render via Replicate · ~30 s</span>'
+              +   '<button class="dec-render" type="button" style="padding:7px 14px;border:0;border-radius:4px;background:var(--gold,#c69b3e);color:#1a1208;font:600 11px var(--sans,system-ui);letter-spacing:.08em;text-transform:uppercase;cursor:pointer">Render →</button>'
+              + '</div>'
+              + '<div class="dec-result" style="margin-top:10px"></div>'
+              + '</div>';
+          }).join('');
+
+      container.querySelectorAll('.dec-render').forEach(btn => {
+        btn.addEventListener('click', async () => {
+          const card = btn.closest('.dec-card');
+          const original = card.dataset.original;
+          const ta = card.querySelector('.dec-prompt');
+          const edited = (ta.value || '').trim();
+          const resultEl = card.querySelector('.dec-result');
+          if (edited.length < 10) { resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">Prompt too short — write at least 10 chars.</div>'; return; }
+          btn.disabled = true; btn.textContent = 'Rendering…';
+          resultEl.innerHTML = '<div style="color:var(--ink-muted,#8a7960);font:12px var(--sans,system-ui)">~30 s on Replicate…</div>';
+          const decBase = { original_prompt: original, edited_prompt: edited, edited: edited !== original, action: 'render' };
+          try {
+            const r = await fetch('/api/admin/inspiration/generate', {
+              method: 'POST', headers: { 'Content-Type': 'application/json' },
+              body: JSON.stringify({ prompts: [edited] }), credentials: 'include'
+            });
+            const j = await r.json().catch(() => ({}));
+            if (!r.ok) {
+              const msg = j.error || ('HTTP ' + r.status);
+              const hint = j.hint ? ' — ' + j.hint : (j.url ? ' (url: ' + j.url + ')' : '');
+              resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">' + escHtml(msg) + escHtml(hint) + '</div>';
+              saveDecision({ ...decBase, result: { error: msg, url: j.url || null } });
+            } else {
+              const d = (j.designs || [])[0] || {};
+              if (d.id) {
+                resultEl.innerHTML =
+                  '<a href="/design/' + d.id + '" style="display:flex;gap:12px;align-items:center;padding:10px;background:#0f0c08;border:1px solid var(--card-border,#3a2f1a);border-radius:4px;text-decoration:none;color:inherit">'
+                  + '<div style="width:60px;height:60px;background:url(\'' + escHtml(d.image_url) + '\') center/cover;border-radius:3px;flex:none"></div>'
+                  + '<div><div style="font:600 13px var(--sans,system-ui)">Design #' + d.id + ' ✓</div>'
+                  + '<div style="font:11px var(--sans,system-ui);color:var(--ink-muted,#8a7960)"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + escHtml(d.dominant_hex || '#888') + ';vertical-align:middle;margin-right:4px"></span>' + escHtml(d.dominant_hex || '') + '</div></div>'
+                  + '</a>';
+                saveDecision({ ...decBase, result: { id: d.id, image_url: d.image_url, dominant_hex: d.dominant_hex } });
+              } else {
+                const err = d.error || 'no_design_returned';
+                const term = d.term ? ' — blocked term: "' + d.term + '"' : '';
+                resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">⚠ ' + escHtml(err) + escHtml(term) + '. Edit the prompt and try again.</div>';
+                saveDecision({ ...decBase, result: { error: err, blocked_term: d.term || null } });
+              }
+            }
+          } catch (e) {
+            resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">Network error: ' + escHtml(e.message) + '</div>';
+            saveDecision({ ...decBase, result: { error: 'network: ' + e.message } });
+          }
+          btn.disabled = false; btn.textContent = 'Render →';
+          refreshDecCount();
+        });
+      });
+
+      document.getElementById('dec-export').addEventListener('click', () => {
+        const data = { exported_at: new Date().toISOString(), decisions: loadDecisions() };
+        const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
+        const url = URL.createObjectURL(blob);
+        const a = document.createElement('a');
+        a.href = url;
+        a.download = 'wallco-decisions-' + new Date().toISOString().slice(0, 10) + '.json';
+        document.body.appendChild(a); a.click(); a.remove();
+        setTimeout(() => URL.revokeObjectURL(url), 1000);
       });
-      const j = await r.json();
-      if (!r.ok) {
-        alert('Generation failed: ' + (j.error || r.status));
-        gen.disabled = false; gen.textContent = orig; return;
-      }
-      const designs = j.designs || [];
-      const made = designs.filter(d => d.id);
-      const blocked = j.blocked_by_settlement || 0;
-      // Render results above grid
-      const out = document.createElement('div');
-      out.style.cssText = 'margin:20px 0;padding:18px;background:var(--card-bg);border:1px solid var(--gold);border-radius:8px';
-      out.innerHTML = `<h2 style="font:300 22px/1 var(--serif);margin:0 0 10px;color:var(--ink)">${made.length} new wallco originals from your pool</h2>
-        ${blocked ? `<p style="margin:0 0 10px;font-size:13px;color:#9a3b2e">⚖ ${blocked} prompt${blocked > 1 ? 's' : ''} blocked by the settlement gate (birds / foliage / prohibited motifs) — click again to retry.</p>` : ''}
-        <div class="p-grid" style="margin-top:10px">
-        ${made.map(d => `
-          <a href="/design/${d.id}" class="p-card" style="text-decoration:none;color:inherit">
-            <div class="p-img" style="background-image:url('${d.image_url}')"></div>
-            <div class="p-meta">
-              <div class="p-name">Design #${d.id}</div>
-              <div class="p-coll"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${d.dominant_hex};vertical-align:middle;margin-right:4px"></span>${d.dominant_hex}</div>
-            </div>
-          </a>`).join('')}
-        </div>`;
-      gen.after(out);
-      gen.textContent = `✓ Generated ${made.length} · click to generate 3 more`;
-      gen.disabled = false;
-    } catch (e) {
-      alert('Generation error: ' + e.message);
-      gen.disabled = false; gen.textContent = orig;
     }
-  });
+
+    gen.addEventListener('click', async () => {
+      gen.disabled = true;
+      gen.textContent = 'Fetching 3 prompts from Ollama… (~15 s)';
+      try {
+        const r = await fetch('/api/admin/inspiration/prompts', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          credentials: 'include'
+        });
+        const j = await r.json().catch(() => ({}));
+        if (!r.ok) {
+          const msg = j.error || ('HTTP ' + r.status);
+          const hint = j.hint ? '\n\n' + j.hint : '';
+          const url = j.url ? '\n\nURL tried: ' + j.url : '';
+          alert('Prompt fetch failed: ' + msg + hint + url);
+          gen.disabled = false; gen.textContent = origGenText; return;
+        }
+        saveDecision({ action: 'fetched_prompts', prompts: j.prompts, settlement_warnings: j.settlement_warnings });
+        const prev = document.getElementById('dec-tray'); if (prev) prev.remove();
+        const out = document.createElement('div');
+        out.id = 'dec-tray';
+        out.style.cssText = 'margin:20px 0';
+        gen.after(out);
+        renderPromptTray(out, j.prompts, j.settlement_warnings || []);
+        gen.textContent = '↺ Re-fetch prompts';
+        gen.disabled = false;
+      } catch (e) {
+        alert('Network error: ' + e.message);
+        gen.disabled = false; gen.textContent = origGenText;
+      }
+    });
+  }
 })();
diff --git a/src/admin.js b/src/admin.js
index c6ded1a..69b6a8c 100644
--- a/src/admin.js
+++ b/src/admin.js
@@ -381,80 +381,109 @@ function mount(app) {
     return m ? { verdict: 'BLOCK', term: m[0] } : { verdict: 'OK' };
   }
 
-  // ── Generate 3 wallco-originals from the admin's inspiration pool.
-  // STRICT NO-LEAK: only pattern_name is sent to the LLM and Replicate.
-  // Vendor names are NEVER included in the prompt that's stored or sent out.
-  // SETTLEMENT GATE: every prompt is screened pre-generation (settlementScreen).
-  // BACKEND: forced to Replicate so the route runs from prod (no local ComfyUI).
-  app.post('/api/admin/inspiration/generate', gate, async (req, res) => {
+  // Calls Ollama with the inspiration items, returns 3 SDXL prompts. Throws
+  // { status, body } on failure so route handlers can `return res.status(s).json(b)`.
+  // Pulled out of /generate so /prompts (Step 1 of the two-step flow) can reuse it.
+  async function fetchLlmPrompts(items) {
+    const scrubbedList = items.slice(0, 8).map(i => `- ${i.pattern_name || 'untitled pattern'}`).join('\n');
+    const sys = [
+      'You are a wallpaper design prompt engineer for wallco.ai.',
+      'Given a short list of pattern names (vendor-anonymous), write 3 distinct SDXL prompts',
+      'for NEW wallpaper designs that share the aesthetic family but are NOT copies.',
+      '',
+      'HARD LEGAL CONSTRAINT — a binding settlement prohibits these; every prompt MUST avoid them:',
+      'birds, butterflies, bananas, banana leaves, banana pods, grapes, monstera leaves, palm fronds,',
+      'and directional variation among leaves/foliage. If an inspiration name implies them',
+      '(e.g. "scenic", "toile", "chinoiserie"), REINTERPRET with settlement-safe motifs instead:',
+      'peony & chrysanthemum florals, pagodas, lattice / fretwork / key-fret geometry, ginger jars',
+      '& vases, stylized clouds and mountains, edge-to-edge coverage, one dominant ink family.',
+      'Crisp opaque screen-print color — never fuzzy or layer-over-layer.',
+      '',
+      'Output ONLY a JSON array of 3 strings — each prompt one paragraph <= 220 chars,',
+      'describing motif + palette + mood + texture. Include "seamless tile, 24-inch repeat,',
+      'archival quality". Never mention a vendor name.'
+    ].join('\n');
+    const userMsg = `Aesthetic inspiration (names only):\n${scrubbedList}\n\nReturn a JSON array of 3 prompts.`;
+
+    const ollamaUrl = process.env.OLLAMA_URL
+      || (process.env.OLLAMA_HOST ? `http://${String(process.env.OLLAMA_HOST).replace(/^https?:\/\//, '')}` : null)
+      || 'http://192.168.1.133:11434';
+
+    let ollText;
+    try {
+      const ollResp = await fetch(`${ollamaUrl}/api/chat`, {
+        method: 'POST', headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          model: process.env.OLLAMA_MODEL || 'qwen3:14b',
+          messages: [{ role: 'system', content: sys }, { role: 'user', content: userMsg }],
+          stream: false, options: { temperature: 0.7 }
+        }),
+        signal: AbortSignal.timeout(60_000)
+      });
+      if (!ollResp.ok) {
+        throw { status: 503, body: { error: 'ollama_http_error', status: ollResp.status, url: ollamaUrl } };
+      }
+      ollText = (await ollResp.json()).message?.content || '';
+    } catch (e) {
+      if (e && e.status && e.body) throw e;
+      throw { status: 503, body: {
+        error: 'ollama_unreachable', url: ollamaUrl,
+        reason: String(e.message || e).slice(0, 200),
+        hint: 'Set OLLAMA_URL (or OLLAMA_HOST) in wallco-ai/.env. For prod use the Mac1→Kamatera Tailscale bridge: OLLAMA_URL=http://100.65.187.120:11435',
+      } };
+    }
+    let prompts = [];
+    try {
+      const m = ollText.match(/\[\s*"[\s\S]*?"\s*\]/);
+      if (m) prompts = JSON.parse(m[0]);
+    } catch {}
+    prompts = (Array.isArray(prompts) ? prompts : []).slice(0, 3).filter(p => typeof p === 'string' && p.length > 20);
+    if (prompts.length === 0) {
+      throw { status: 502, body: { error: 'llm_returned_no_prompts', raw: ollText.slice(0, 400) } };
+    }
+    return { prompts, raw: ollText };
+  }
+
+  // ── Step 1 of the two-step flow — pool → 3 SDXL prompts, LLM only, NO render.
+  // Returns per-prompt settlement_warnings so the admin can edit risky prompts
+  // BEFORE burning a Replicate call on something the settlement gate will block.
+  app.post('/api/admin/inspiration/prompts', gate, async (req, res) => {
     try {
       const items = inspirationFor(req.admin.email);
       if (items.length === 0) return res.status(400).json({ error: 'pool_empty' });
+      const { prompts } = await fetchLlmPrompts(items);
+      const settlement_warnings = prompts.map(p => settlementScreen(p));
+      res.json({ ok: true, prompts, settlement_warnings });
+    } catch (e) {
+      if (e && e.status && e.body) return res.status(e.status).json(e.body);
+      res.status(500).json({ error: e.message });
+    }
+  });
 
-      // 1) Ask Ollama qwen3:14b to translate the items into 3 settlement-safe SDXL prompts.
-      const scrubbedList = items.slice(0, 8).map(i => `- ${i.pattern_name || 'untitled pattern'}`).join('\n');
-      const sys = [
-        'You are a wallpaper design prompt engineer for wallco.ai.',
-        'Given a short list of pattern names (vendor-anonymous), write 3 distinct SDXL prompts',
-        'for NEW wallpaper designs that share the aesthetic family but are NOT copies.',
-        '',
-        'HARD LEGAL CONSTRAINT — a binding settlement prohibits these; every prompt MUST avoid them:',
-        'birds, butterflies, bananas, banana leaves, banana pods, grapes, monstera leaves, palm fronds,',
-        'and directional variation among leaves/foliage. If an inspiration name implies them',
-        '(e.g. "scenic", "toile", "chinoiserie"), REINTERPRET with settlement-safe motifs instead:',
-        'peony & chrysanthemum florals, pagodas, lattice / fretwork / key-fret geometry, ginger jars',
-        '& vases, stylized clouds and mountains, edge-to-edge coverage, one dominant ink family.',
-        'Crisp opaque screen-print color — never fuzzy or layer-over-layer.',
-        '',
-        'Output ONLY a JSON array of 3 strings — each prompt one paragraph <= 220 chars,',
-        'describing motif + palette + mood + texture. Include "seamless tile, 24-inch repeat,',
-        'archival quality". Never mention a vendor name.'
-      ].join('\n');
-      const userMsg = `Aesthetic inspiration (names only):\n${scrubbedList}\n\nReturn a JSON array of 3 prompts.`;
-
-      // Ollama URL — explicit OLLAMA_URL wins; else OLLAMA_HOST (host:port form
-      // used by the Mac1→Kamatera Tailscale bridge convention); else fall back to
-      // Mac1's LAN address, which prod CANNOT reach — so prod must set one of
-      // OLLAMA_URL / OLLAMA_HOST in wallco-ai/.env or the try/catch below fires
-      // with an actionable error (no more generic 500 on "Generate 3 originals").
-      const ollamaUrl = process.env.OLLAMA_URL
-        || (process.env.OLLAMA_HOST ? `http://${String(process.env.OLLAMA_HOST).replace(/^https?:\/\//, '')}` : null)
-        || 'http://192.168.1.133:11434';
-
-      let ollText;
-      try {
-        const ollResp = await fetch(`${ollamaUrl}/api/chat`, {
-          method: 'POST', headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({
-            model: process.env.OLLAMA_MODEL || 'qwen3:14b',
-            messages: [{ role: 'system', content: sys }, { role: 'user', content: userMsg }],
-            stream: false, options: { temperature: 0.7 }
-          }),
-          signal: AbortSignal.timeout(60_000)
-        });
-        if (!ollResp.ok) {
-          return res.status(503).json({
-            error: 'ollama_http_error', status: ollResp.status, url: ollamaUrl,
-          });
+  // ── Generate wallco-originals from the admin's inspiration pool.
+  // Two-step flow — client first hits /prompts (above), admin edits the
+  // returned text, then POSTs the edited prompts here as `body.prompts`.
+  // Legacy end-to-end — if `body.prompts` is absent, runs the LLM internally
+  // and renders the unedited result (the original /generate behaviour).
+  // STRICT NO-LEAK: only pattern_name is sent to the LLM; vendor names never
+  // leave the server. SETTLEMENT GATE screens every prompt before Replicate.
+  // BACKEND: forced to Replicate so the route runs from prod (no local ComfyUI).
+  app.post('/api/admin/inspiration/generate', gate, async (req, res) => {
+    try {
+      // Step 2 mode — admin-supplied (possibly edited) prompts skip the LLM.
+      // Step 1 mode — no body.prompts → pool→LLM→render end-to-end (legacy).
+      let prompts = Array.isArray(req.body && req.body.prompts)
+        ? req.body.prompts.filter(p => typeof p === 'string' && p.length > 10).slice(0, 5)
+        : null;
+      if (!prompts || !prompts.length) {
+        const items = inspirationFor(req.admin.email);
+        if (items.length === 0) return res.status(400).json({ error: 'pool_empty' });
+        try { prompts = (await fetchLlmPrompts(items)).prompts; }
+        catch (e) {
+          if (e && e.status && e.body) return res.status(e.status).json(e.body);
+          return res.status(500).json({ error: e.message });
         }
-        ollText = (await ollResp.json()).message?.content || '';
-      } catch (e) {
-        return res.status(503).json({
-          error: 'ollama_unreachable',
-          url: ollamaUrl,
-          reason: String(e.message || e).slice(0, 200),
-          hint: 'Set OLLAMA_URL (or OLLAMA_HOST) in wallco-ai/.env. For prod use the Mac1→Kamatera Tailscale bridge: OLLAMA_URL=http://100.65.187.120:11435',
-        });
-      }
-      let prompts = [];
-      try {
-        const m = ollText.match(/\[\s*"[\s\S]*?"\s*\]/);
-        if (m) prompts = JSON.parse(m[0]);
-      } catch {}
-      if (!Array.isArray(prompts) || prompts.length === 0) {
-        return res.status(502).json({ error: 'llm_returned_no_prompts', raw: ollText.slice(0, 400) });
       }
-      prompts = prompts.slice(0, 3).filter(p => typeof p === 'string' && p.length > 20);
 
       // 2) Generate via Replicate SDXL (DTD verdict B — runs from prod, no local
       //    ComfyUI dependency). spawnSync with an arg array = no shell injection

← b6b1b00 /design page: show full palette as paint chips + paint strip  ·  back to Wallco Ai  ·  feat(scripts): add cull-viewer-server.js 2e90f4f →