← back to Wallco Ai
wallco.ai chat layer: POST /api/chat/catalog + /api/chat/design/:id · qwen3:14b on Mac1 with tool-use (search + generate) · Replicate SDXL for new+variant designs · floating chat panel auto-mounts on every page, context-aware (catalog vs design:id) · 5 PG tables (sessions, messages, parent_design_id lineage)
5f79c253c219048d08117f8a750e52f9c595ffbe · 2026-05-11 16:57:43 -0700 · Steve
Files touched
A db/migrations/002_chat.sqlA public/js/chat.jsM scripts/__pycache__/spoonflower_lib.cpython-314.pycM server.jsA src/chat.js
Diff
commit 5f79c253c219048d08117f8a750e52f9c595ffbe
Author: Steve <steve@designerwallcoverings.com>
Date: Mon May 11 16:57:43 2026 -0700
wallco.ai chat layer: POST /api/chat/catalog + /api/chat/design/:id · qwen3:14b on Mac1 with tool-use (search + generate) · Replicate SDXL for new+variant designs · floating chat panel auto-mounts on every page, context-aware (catalog vs design:id) · 5 PG tables (sessions, messages, parent_design_id lineage)
---
db/migrations/002_chat.sql | 33 ++
public/js/chat.js | 188 ++++++++++++
.../__pycache__/spoonflower_lib.cpython-314.pyc | Bin 7474 -> 13765 bytes
server.js | 6 +-
src/chat.js | 339 +++++++++++++++++++++
5 files changed, 565 insertions(+), 1 deletion(-)
diff --git a/db/migrations/002_chat.sql b/db/migrations/002_chat.sql
new file mode 100644
index 0000000..e2bd7f6
--- /dev/null
+++ b/db/migrations/002_chat.sql
@@ -0,0 +1,33 @@
+-- wallco.ai chat + design lineage
+
+CREATE TABLE IF NOT EXISTS wallco_chat_sessions (
+ id BIGSERIAL PRIMARY KEY,
+ session_uuid TEXT UNIQUE NOT NULL,
+ context TEXT NOT NULL DEFAULT 'catalog', -- 'catalog' | 'design:<id>'
+ visitor_ip INET,
+ visitor_email TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ last_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS wallco_chat_messages (
+ id BIGSERIAL PRIMARY KEY,
+ session_id BIGINT NOT NULL REFERENCES wallco_chat_sessions(id),
+ role TEXT NOT NULL CHECK (role IN ('user','assistant','tool','system')),
+ content TEXT NOT NULL,
+ tool_name TEXT,
+ tool_args JSONB,
+ tool_result JSONB,
+ generated_design_id BIGINT, -- if a generate_design tool fired, links here
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_wallco_chat_session_uuid ON wallco_chat_sessions(session_uuid);
+CREATE INDEX IF NOT EXISTS idx_wallco_chat_session_last ON wallco_chat_sessions(last_at DESC);
+CREATE INDEX IF NOT EXISTS idx_wallco_chat_msg_session ON wallco_chat_messages(session_id, created_at);
+
+-- Design lineage — when a design is generated via "vary this one", we link parent → child
+ALTER TABLE spoon_all_designs ADD COLUMN IF NOT EXISTS parent_design_id BIGINT REFERENCES spoon_all_designs(id);
+ALTER TABLE spoon_all_designs ADD COLUMN IF NOT EXISTS chat_session_id BIGINT;
+ALTER TABLE spoon_all_designs ADD COLUMN IF NOT EXISTS request_text TEXT; -- user's natural-language ask
+CREATE INDEX IF NOT EXISTS idx_spoon_parent ON spoon_all_designs(parent_design_id);
diff --git a/public/js/chat.js b/public/js/chat.js
new file mode 100644
index 0000000..b961117
--- /dev/null
+++ b/public/js/chat.js
@@ -0,0 +1,188 @@
+/* wallco.ai chat panel — drop-in widget.
+ *
+ * Usage:
+ * <script src="/js/chat.js" defer
+ * data-context="catalog" ← "catalog" or "design:<id>"
+ * data-cta="Chat with our designer"></script>
+ *
+ * Renders a floating chat button → opens a panel pinned to the bottom-right.
+ * Persists session_uuid in localStorage so conversations survive reloads.
+ */
+(function () {
+ if (window.__WALLCO_CHAT_LOADED__) return; window.__WALLCO_CHAT_LOADED__ = true;
+ const tag = document.currentScript || document.querySelector('script[src*="/js/chat.js"]');
+ // Auto-detect /design/:id URLs; otherwise catalog
+ let CTX_KEY = (tag && tag.dataset.context) || 'catalog';
+ const designMatch = location.pathname.match(/^\/design\/(\d+)/);
+ if (designMatch && CTX_KEY === 'catalog') CTX_KEY = 'design:' + designMatch[1];
+ const CTA = (tag && tag.dataset.cta) || (CTX_KEY.startsWith('design:') ? 'Request a variant' : 'Chat with our designer');
+ const STORAGE_KEY = 'wallco.chat.' + CTX_KEY;
+ const isDesign = CTX_KEY.startsWith('design:');
+ const designId = isDesign ? CTX_KEY.split(':')[1] : null;
+ const ENDPOINT = isDesign ? `/api/chat/design/${designId}` : '/api/chat/catalog';
+
+ // ─── styles
+ const css = `
+ #wallco-chat-fab { position:fixed; bottom:24px; right:24px; z-index:9998; background:#1a1a1a; color:#fff; border:0; border-radius:999px; padding:14px 20px; font:14px/1 -apple-system,system-ui,sans-serif; font-weight:500; cursor:pointer; box-shadow:0 8px 28px rgba(0,0,0,.3); }
+ #wallco-chat-fab:hover { background:#2a2a2a; }
+ #wallco-chat-panel { position:fixed; bottom:24px; right:24px; width:380px; max-width:92vw; height:560px; max-height:80vh; background:#fff; border-radius:14px; box-shadow:0 24px 64px rgba(0,0,0,.35); z-index:9999; display:none; flex-direction:column; overflow:hidden; font:14px/1.45 -apple-system,system-ui,sans-serif; color:#1a1a1a; }
+ #wallco-chat-panel.is-open { display:flex; }
+ #wallco-chat-panel header { padding:14px 18px; border-bottom:1px solid #ede8df; background:#faf8f5; display:flex; align-items:center; gap:10px; }
+ #wallco-chat-panel header .title { font-weight:500; font-size:13px; }
+ #wallco-chat-panel header .sub { font-size:11px; color:#888; }
+ #wallco-chat-panel header .close { margin-left:auto; background:transparent; border:0; cursor:pointer; font-size:18px; color:#888; }
+ #wallco-chat-log { flex:1; padding:14px 18px; overflow-y:auto; display:flex; flex-direction:column; gap:10px; }
+ .wc-msg { max-width:85%; padding:9px 12px; border-radius:10px; font-size:13px; line-height:1.45; }
+ .wc-msg.user { background:#1a1a1a; color:#fff; align-self:flex-end; border-bottom-right-radius:3px; }
+ .wc-msg.assistant { background:#f3eee5; color:#1a1a1a; align-self:flex-start; border-bottom-left-radius:3px; }
+ .wc-msg.thinking { background:#f3eee5; color:#888; align-self:flex-start; font-style:italic; }
+ .wc-msg.error { background:#fde6e1; color:#7a2118; align-self:flex-start; }
+ .wc-actions { align-self:stretch; display:grid; grid-template-columns:repeat(auto-fill, minmax(100px,1fr)); gap:8px; margin-top:4px; }
+ .wc-card { background:#fff; border:1px solid #ede8df; border-radius:6px; overflow:hidden; cursor:pointer; transition:transform .15s, border-color .15s; }
+ .wc-card:hover { transform:translateY(-2px); border-color:#1a1a1a; }
+ .wc-card img { width:100%; aspect-ratio:1; object-fit:cover; display:block; }
+ .wc-card .cap { padding:4px 6px; font-size:10px; color:#666; }
+ .wc-new { align-self:stretch; background:#fff; border:1px solid #d2b15c; border-radius:8px; padding:12px; display:flex; gap:10px; align-items:center; }
+ .wc-new img { width:80px; height:80px; object-fit:cover; border-radius:4px; }
+ .wc-new .meta { flex:1; font-size:12px; }
+ .wc-new .meta .title { font-weight:500; margin-bottom:2px; }
+ .wc-new .meta a { color:#1a1a1a; text-decoration:underline; font-size:12px; }
+ #wallco-chat-form { padding:12px 14px; border-top:1px solid #ede8df; display:flex; gap:8px; background:#faf8f5; }
+ #wallco-chat-form textarea { flex:1; border:1px solid #d0c8b8; border-radius:6px; padding:8px 10px; font:inherit; resize:none; height:38px; max-height:120px; }
+ #wallco-chat-form button { background:#1a1a1a; color:#fff; border:0; border-radius:6px; padding:0 14px; font-weight:500; cursor:pointer; }
+ #wallco-chat-form button:disabled { background:#999; cursor:wait; }
+ .wc-suggest { display:flex; gap:6px; flex-wrap:wrap; padding:0 14px 10px; background:#faf8f5; }
+ .wc-suggest button { font:11px -apple-system,system-ui; background:transparent; border:1px solid #d0c8b8; color:#555; padding:5px 9px; border-radius:999px; cursor:pointer; }
+ .wc-suggest button:hover { background:#fff; color:#1a1a1a; }`;
+ const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style);
+
+ // ─── DOM
+ const fab = document.createElement('button');
+ fab.id = 'wallco-chat-fab'; fab.type = 'button'; fab.textContent = '✦ ' + CTA;
+ document.body.appendChild(fab);
+
+ const panel = document.createElement('div');
+ panel.id = 'wallco-chat-panel';
+ panel.innerHTML = `
+ <header>
+ <div>
+ <div class="title">${isDesign ? 'Vary this design' : 'wallco.ai designer'}</div>
+ <div class="sub">${isDesign ? 'Ask for a variant — color, scale, mood' : 'Browse, filter, or generate new'}</div>
+ </div>
+ <button class="close" type="button" aria-label="Close">×</button>
+ </header>
+ <div id="wallco-chat-log"></div>
+ <div class="wc-suggest">
+ ${(isDesign
+ ? ['Make it more muted', 'Swap gold for silver', 'Tighter pattern']
+ : ['Show me sapphire damasks', 'Olive florals please', 'Make me an art deco fan in brass']
+ ).map(s => `<button type="button">${s}</button>`).join('')}
+ </div>
+ <form id="wallco-chat-form">
+ <textarea placeholder="${isDesign ? 'How should we change it?' : 'What are you looking for? Or ask us to make one.'}" rows="1"></textarea>
+ <button type="submit">Send</button>
+ </form>`;
+ document.body.appendChild(panel);
+
+ const log = panel.querySelector('#wallco-chat-log');
+ const form = panel.querySelector('#wallco-chat-form');
+ const ta = form.querySelector('textarea');
+ const submit = form.querySelector('button');
+ const closeBtn= panel.querySelector('.close');
+ fab.addEventListener('click', () => { panel.classList.add('is-open'); fab.style.display = 'none'; ta.focus(); });
+ closeBtn.addEventListener('click', () => { panel.classList.remove('is-open'); fab.style.display = ''; });
+
+ // Suggestion chips
+ panel.querySelectorAll('.wc-suggest button').forEach(b => b.addEventListener('click', () => {
+ ta.value = b.textContent; form.requestSubmit();
+ }));
+
+ // Restore session
+ let sessionUuid = localStorage.getItem(STORAGE_KEY) || null;
+ if (sessionUuid) restoreSession();
+
+ async function restoreSession() {
+ try {
+ const r = await fetch('/api/chat/session/' + sessionUuid);
+ if (!r.ok) return;
+ const j = await r.json();
+ (j.messages || []).forEach(m => addMsg(m.role, m.content));
+ } catch {}
+ }
+
+ function addMsg(role, text) {
+ const d = document.createElement('div');
+ d.className = 'wc-msg ' + role;
+ d.textContent = text;
+ log.appendChild(d); log.scrollTop = log.scrollHeight;
+ return d;
+ }
+ function addThinking() {
+ const d = document.createElement('div');
+ d.className = 'wc-msg thinking'; d.textContent = '…';
+ log.appendChild(d); log.scrollTop = log.scrollHeight;
+ return d;
+ }
+ function addSearchGrid(designs) {
+ const wrap = document.createElement('div'); wrap.className = 'wc-actions';
+ designs.forEach(d => {
+ const c = document.createElement('a');
+ c.className = 'wc-card'; c.href = '/design/' + d.id; c.target = '_self';
+ c.innerHTML = `<img src="${d.image_url}" alt="${d.title}"><div class="cap">${d.title.slice(0,40)}</div>`;
+ wrap.appendChild(c);
+ });
+ log.appendChild(wrap); log.scrollTop = log.scrollHeight;
+ }
+ function addNewDesign(design, label) {
+ const w = document.createElement('div'); w.className = 'wc-new';
+ w.innerHTML = `<img src="${design.image_url}" alt="generated">
+ <div class="meta">
+ <div class="title">${label || 'Just generated'}</div>
+ <div>Color: <span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${design.dominant_hex};vertical-align:middle"></span> ${design.dominant_hex}</div>
+ <a href="/design/${design.id}">View this design →</a>
+ </div>`;
+ log.appendChild(w); log.scrollTop = log.scrollHeight;
+ }
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const msg = ta.value.trim(); if (!msg) return;
+ addMsg('user', msg);
+ ta.value = ''; submit.disabled = true;
+ const thinking = addThinking();
+ try {
+ const r = await fetch(ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message: msg, session_uuid: sessionUuid })
+ });
+ const j = await r.json();
+ thinking.remove();
+ if (j.session_uuid && j.session_uuid !== sessionUuid) {
+ sessionUuid = j.session_uuid; localStorage.setItem(STORAGE_KEY, sessionUuid);
+ }
+ const reply = (j.reply || '').replace(/```[\s\S]*?```/g, '').trim();
+ if (reply) addMsg('assistant', reply);
+ else if (j.actions && j.actions[0]) {
+ // Server didn't render a preamble — synthesize one
+ if (j.actions[0].type === 'search_results') addMsg('assistant', `Here are ${j.actions[0].count} matches:`);
+ if (j.actions[0].type === 'new_design') addMsg('assistant', 'Generated a new design for you.');
+ if (j.actions[0].type === 'variant') addMsg('assistant', 'Here\\'s a variant.');
+ }
+ (j.actions || []).forEach(a => {
+ if (a.type === 'search_results') addSearchGrid(a.designs || []);
+ if (a.type === 'new_design') addNewDesign(a.design, 'New original');
+ if (a.type === 'variant') addNewDesign(a.design, 'Variant of #' + a.parent_id);
+ });
+ } catch (err) {
+ thinking.remove();
+ const e2 = document.createElement('div'); e2.className = 'wc-msg error'; e2.textContent = 'Sorry — ' + err.message; log.appendChild(e2);
+ }
+ submit.disabled = false; ta.focus();
+ });
+
+ // Enter to submit (Shift+Enter = newline)
+ ta.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
+ });
+})();
diff --git a/scripts/__pycache__/spoonflower_lib.cpython-314.pyc b/scripts/__pycache__/spoonflower_lib.cpython-314.pyc
index a23e463..ca8e1a8 100644
Binary files a/scripts/__pycache__/spoonflower_lib.cpython-314.pyc and b/scripts/__pycache__/spoonflower_lib.cpython-314.pyc differ
diff --git a/server.js b/server.js
index 3a557cb..b96f6ba 100644
--- a/server.js
+++ b/server.js
@@ -53,6 +53,9 @@ function psqlQuery(sql) {
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
+// ── Chat layer: catalog + per-design variation
+try { require('./src/chat').mount(app); console.log(' Chat layer mounted'); } catch (e) { console.error('Chat mount failed:', e.message); }
+
// ── Cache-Control: no-store on HTML (per feedback_cloudflare_html_caching.md)
app.use((req, res, next) => {
if (req.accepts('html') && !req.path.startsWith('/designs/img')) {
@@ -332,7 +335,8 @@ const FOOTER = `<footer class="site-footer">
<p>wallco.ai — AI-original wallpaper & murals. Every pattern generated, never repeated.</p>
<p>Part of the <a href="https://designerwallcoverings.com">Designer Wallcoverings</a> family.</p>
<p><a href="mailto:info@wallco.ai">info@wallco.ai</a></p>
-</footer>`;
+</footer>
+<script src="/js/chat.js" defer></script>`;
const HAMBURGER_JS = `
<!-- First-visit Age Prompt (drives age-adaptive UI per ageBand) -->
diff --git a/src/chat.js b/src/chat.js
new file mode 100644
index 0000000..c6693b2
--- /dev/null
+++ b/src/chat.js
@@ -0,0 +1,339 @@
+/**
+ * wallco.ai chat layer — Express routes for catalog chat + per-design variations.
+ *
+ * Backend: Ollama qwen3:14b on Mac1 (free, local). Tool-use via JSON.
+ * Image gen: Replicate SDXL (same wiring as scripts/generate_designs.js).
+ *
+ * Two routes:
+ * POST /api/chat/catalog → "I want sapphire damasks" / "make me a new …"
+ * POST /api/chat/design/:id → "make this more muted" / "swap gold for silver"
+ *
+ * Body: { message, session_uuid? }
+ * Response: { reply, session_uuid, actions: [{type, ...}] }
+ * action types: search_results (existing matches), new_design (generated row), variant (new spoon_all row with parent_design_id)
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { execSync, spawnSync } = require('child_process');
+
+const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
+const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b';
+const REPLICATE_VERSION = process.env.SDXL_MODEL_VERSION || '7762fd07cf82c948538e41f63f77d685e02b063e37e496e96eefd46c929f9bdc';
+const IMG_DIR = path.join(__dirname, '..', 'data', 'generated');
+fs.mkdirSync(IMG_DIR, { recursive: true });
+
+function loadReplicateToken() {
+ if (process.env.REPLICATE_API_TOKEN) return process.env.REPLICATE_API_TOKEN;
+ for (const p of [
+ `${process.env.HOME}/Projects/animate-museum-posts/.env`,
+ `${process.env.HOME}/Projects/secrets-manager/.env`
+ ]) {
+ if (!fs.existsSync(p)) continue;
+ const m = fs.readFileSync(p, 'utf8').match(/^REPLICATE_API_TOKEN=(\S+)/m);
+ if (m) return m[1].replace(/^["']|["']$/g, '');
+ }
+ return null;
+}
+
+function psql(sql) {
+ return execSync(`psql dw_unified -At -q`, { input: sql, encoding: 'utf8' }).trim();
+}
+function esc(s) { if (s == null) return 'NULL'; return "'" + String(s).replace(/'/g, "''") + "'"; }
+
+// -------- Ollama call with tool-use protocol --------
+async function ollamaChat({ system, history, message }) {
+ const msgs = [{ role: 'system', content: system }, ...history, { role: 'user', content: message }];
+ const res = await fetch(`${OLLAMA}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: OLLAMA_MODEL, messages: msgs, stream: false, options: { temperature: 0.4 } }),
+ signal: AbortSignal.timeout(60_000)
+ });
+ if (!res.ok) throw new Error(`ollama ${res.status}: ${await res.text()}`);
+ const j = await res.json();
+ return j.message ? j.message.content : '';
+}
+
+function extractToolCall(text) {
+ // Look for JSON code blocks or bare JSON the LLM emitted.
+ const block = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
+ if (block) { try { return JSON.parse(block[1]); } catch {} }
+ const bare = text.match(/\{\s*"tool"\s*:\s*"[^"]+"[\s\S]*?\}\s*$/);
+ if (bare) { try { return JSON.parse(bare[0]); } catch {} }
+ return null;
+}
+
+// -------- Tool implementations --------
+
+function toolSearchDesigns({ category, hue, hex_near, motif_kw, limit = 12 } = {}) {
+ const where = [`generator='replicate'`];
+ if (category) where.push(`category=${esc(category)}`);
+ if (motif_kw) where.push(`prompt ILIKE ${esc('%' + motif_kw + '%')}`);
+ const lim = Math.min(parseInt(limit, 10) || 12, 30);
+ const sql = `SELECT COALESCE(json_agg(t),'[]'::json) FROM (
+ SELECT id, kind, category, dominant_hex, prompt, local_path
+ FROM spoon_all_designs WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT ${lim}) t;`;
+ const rows = JSON.parse(psql(sql) || '[]');
+ // Hex-near filter (post-query): Lab distance in JS is overkill; use Euclidean in RGB
+ let filtered = rows;
+ if (hex_near && /^#[0-9a-fA-F]{6}$/.test(hex_near)) {
+ const target = hexToRgb(hex_near);
+ filtered = rows
+ .map(r => ({ ...r, _dist: r.dominant_hex ? rgbDist(target, hexToRgb(r.dominant_hex)) : 9999 }))
+ .filter(r => r._dist < 80)
+ .sort((a, b) => a._dist - b._dist);
+ } else if (hue) {
+ const huedeg = hueNameToDeg(hue);
+ filtered = rows.filter(r => withinHue(r.dominant_hex, huedeg, 30));
+ }
+ return filtered.slice(0, lim).map(r => ({
+ id: r.id,
+ title: titleFromPrompt(r.prompt),
+ category: r.category,
+ hex: r.dominant_hex,
+ image_url: r.local_path ? '/designs/img/' + path.basename(r.local_path) : null
+ }));
+}
+
+function titleFromPrompt(p) {
+ if (!p) return '';
+ const first = p.split(',')[0].trim();
+ return first.length > 70 ? first.slice(0, 67) + '…' : first;
+}
+
+function hexToRgb(h) {
+ h = h.replace('#', '');
+ return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
+}
+function rgbDist(a, b) { return Math.sqrt((a[0]-b[0])**2 + (a[1]-b[1])**2 + (a[2]-b[2])**2); }
+function hueNameToDeg(name) {
+ const map = { rose:0, amber:25, honey:45, olive:70, sage:120, marine:180, sapphire:220, blue:220, mauve:270, plum:310, gold:45 };
+ return map[(name || '').toLowerCase()] ?? 0;
+}
+function withinHue(hex, deg, tolerance) {
+ if (!hex) return false;
+ const [r, g, b] = hexToRgb(hex).map(v => v / 255);
+ const max = Math.max(r,g,b), min = Math.min(r,g,b);
+ if (max === min) return false;
+ let h = 0;
+ const d = max - min;
+ if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
+ else if (max === g) h = (b - r) / d + 2;
+ else h = (r - g) / d + 4;
+ h *= 60;
+ const diff = Math.min(Math.abs(h - deg), 360 - Math.abs(h - deg));
+ return diff <= tolerance;
+}
+
+async function toolGenerateDesign({ prompt, category = 'mixed' }, ctx = {}) {
+ const TOKEN = loadReplicateToken();
+ if (!TOKEN) throw new Error('REPLICATE_API_TOKEN missing');
+ const seed = crypto.randomInt(1, 2 ** 31 - 1);
+ const positive = `${prompt}, seamless tile, repeating pattern, no edges, fabric pattern, wallpaper design, archival quality, high detail`;
+ const negative = 'low quality, blurry, edges, seam, border, frame, signature, watermark, text, hands, fingers, deformed';
+ const body = {
+ version: REPLICATE_VERSION,
+ input: {
+ prompt: positive, negative_prompt: negative,
+ width: 1024, height: 1024, num_inference_steps: 28, guidance_scale: 7.5,
+ seed, refine: 'expert_ensemble_refiner', apply_watermark: false
+ }
+ };
+ const submit = execSync(`curl -sf -m 30 -H 'Authorization: Bearer ${TOKEN}' -H 'Content-Type: application/json' -X POST 'https://api.replicate.com/v1/predictions' -d @-`,
+ { input: JSON.stringify(body), encoding: 'utf8' });
+ const pred = JSON.parse(submit);
+ if (!pred.id) throw new Error('replicate no prediction id');
+ // Poll
+ const start = Date.now();
+ let final;
+ while (Date.now() - start < 3 * 60_000) {
+ execSync('sleep 3');
+ const p = JSON.parse(execSync(`curl -sf -m 10 -H 'Authorization: Bearer ${TOKEN}' 'https://api.replicate.com/v1/predictions/${pred.id}'`, { encoding: 'utf8' }));
+ if (p.status === 'succeeded') { final = p; break; }
+ if (p.status === 'failed' || p.status === 'canceled') throw new Error(`replicate ${p.status}: ${JSON.stringify(p.error).slice(0, 200)}`);
+ }
+ if (!final) throw new Error('replicate timed out');
+ const imageUrl = Array.isArray(final.output) ? final.output[0] : final.output;
+ const filename = `${Date.now()}_${seed}.png`;
+ const outPath = path.join(IMG_DIR, filename);
+ execSync(`curl -sf -m 60 -L -o ${JSON.stringify(outPath)} ${JSON.stringify(imageUrl)}`);
+ // Extract palette
+ const pyOut = spawnSync('python3', ['-c', `
+from PIL import Image
+from collections import Counter
+import json, sys
+img = Image.open(sys.argv[1]).convert('RGB')
+img.thumbnail((300,300))
+pal = img.quantize(colors=5, method=Image.Quantize.MEDIANCUT).convert('RGB')
+pixels = list(pal.getdata()); cnt = Counter(pixels); total = sum(cnt.values())
+palette = [{'hex':'#{:02x}{:02x}{:02x}'.format(*rgb), 'pct':round(n/total*100,2)} for rgb,n in cnt.most_common(5)]
+print(json.dumps(palette))
+`, outPath], { encoding: 'utf8' });
+ let palette = [], dominant = null;
+ try { palette = JSON.parse(pyOut.stdout.trim()); dominant = palette[0]?.hex; } catch {}
+ const palJson = JSON.stringify(palette).replace(/'/g, "''");
+ const promptEsc = prompt.replace(/'/g, "''");
+ const insertSql = `INSERT INTO spoon_all_designs (kind, width_in, height_in, generator, prompt, seed, dominant_hex, palette, local_path, category, is_published, parent_design_id, chat_session_id, request_text)
+ VALUES ('seamless_tile', 24, 24, 'replicate', '${promptEsc}', ${seed}, ${dominant ? "'" + dominant + "'" : 'NULL'}, '${palJson}'::jsonb, ${esc(outPath)}, '${category.replace(/'/g,"''")}', FALSE, ${ctx.parent_design_id ? ctx.parent_design_id : 'NULL'}, ${ctx.chat_session_id ? ctx.chat_session_id : 'NULL'}, ${esc(ctx.request_text)}) RETURNING id;`;
+ const id = parseInt(psql(insertSql), 10);
+ return { id, prompt, dominant_hex: dominant, category, image_url: '/designs/img/' + filename, parent_design_id: ctx.parent_design_id || null };
+}
+
+// -------- Session helpers --------
+
+function ensureSession({ session_uuid, context, ip }) {
+ let s = session_uuid;
+ if (!s) s = crypto.randomBytes(12).toString('hex');
+ const row = psql(`SELECT id FROM wallco_chat_sessions WHERE session_uuid=${esc(s)};`);
+ if (row) return { id: parseInt(row, 10), session_uuid: s };
+ const id = parseInt(psql(`INSERT INTO wallco_chat_sessions (session_uuid, context, visitor_ip) VALUES (${esc(s)}, ${esc(context)}, ${ip ? esc(ip) : 'NULL'}) RETURNING id;`), 10);
+ return { id, session_uuid: s };
+}
+
+function loadHistory(sessionId, n = 8) {
+ const rows = JSON.parse(psql(`SELECT COALESCE(json_agg(t),'[]'::json) FROM (
+ SELECT role, content FROM wallco_chat_messages WHERE session_id=${sessionId} AND role IN ('user','assistant') ORDER BY created_at DESC LIMIT ${n}) t;`) || '[]');
+ return rows.reverse();
+}
+
+function logMessage(sessionId, role, content, extras = {}) {
+ const tn = extras.tool_name ? esc(extras.tool_name) : 'NULL';
+ const ta = extras.tool_args ? `${esc(JSON.stringify(extras.tool_args))}::jsonb` : 'NULL';
+ const tr = extras.tool_result ? `${esc(JSON.stringify(extras.tool_result))}::jsonb` : 'NULL';
+ const gid = extras.generated_design_id || 'NULL';
+ psql(`INSERT INTO wallco_chat_messages (session_id, role, content, tool_name, tool_args, tool_result, generated_design_id) VALUES (${sessionId}, ${esc(role)}, ${esc(content)}, ${tn}, ${ta}, ${tr}, ${gid});`);
+ psql(`UPDATE wallco_chat_sessions SET last_at=NOW() WHERE id=${sessionId};`);
+}
+
+// -------- System prompts --------
+
+const SYS_CATALOG = `You are the wallco.ai design concierge. wallco.ai sells AI-original wallpapers and murals — every pattern is generated, never repeated.
+
+You have two tools you can call, by emitting a JSON block at the END of your message:
+
+\`\`\`json
+{"tool":"search","args":{"category":"floral|geometric|damask|mixed","hue":"sapphire|amber|sage|rose|olive|plum|...","hex_near":"#aabbcc","motif_kw":"peony","limit":12}}
+\`\`\`
+
+\`\`\`json
+{"tool":"generate","args":{"prompt":"<vivid pattern description in 1-2 sentences>","category":"floral|geometric|damask|mixed"}}
+\`\`\`
+
+ALWAYS:
+- For "show me X" / "I'd like to browse Y" → tool=search
+- For "make me a new X" / "design something with Y" → tool=generate
+- Use only ONE tool per response.
+- Write a 1-2 sentence preamble before the JSON block to explain what you're about to do.
+- Keep the user's stated colors and motifs in the generate prompt.
+
+Categories: floral, geometric, damask, mixed.
+Color hue names you can use: rose, amber, honey, olive, sage, marine, sapphire, mauve, plum.`;
+
+const SYS_DESIGN = (d) => `You are the wallco.ai design concierge, in dialogue about ONE specific design.
+
+This design:
+- ID: ${d.id}
+- Category: ${d.category}
+- Dominant color: ${d.dominant_hex}
+- Original prompt: "${d.prompt}"
+
+The user wants a VARIANT of this design with their requested change. Your job:
+1. Acknowledge what they want changed in 1 sentence.
+2. Rewrite the prompt to incorporate the change while keeping the design's identity.
+3. Emit a JSON tool call:
+
+\`\`\`json
+{"tool":"generate","args":{"prompt":"<new full prompt>","category":"${d.category}"}}
+\`\`\`
+
+Examples of changes you handle:
+- "Make it more muted" → strip saturation words, add "soft, muted, dusty"
+- "Swap gold for silver" → replace gold/brass/ochre with silver/pewter/platinum
+- "Tighter pattern" → add "small-scale, dense repeat"
+- "More airy" → add "open negative space, sparse"
+Stay faithful to the underlying motif (peony, lattice, damask, etc).`;
+
+// -------- Public Express factory --------
+
+function mount(app) {
+ // Catalog chat
+ app.post('/api/chat/catalog', async (req, res) => {
+ try {
+ const { message, session_uuid } = req.body || {};
+ if (!message || typeof message !== 'string') return res.status(400).json({ error: 'message required' });
+ const ip = (req.get('x-forwarded-for') || req.ip || '').split(',')[0].trim();
+ const { id: sessionId, session_uuid: uuid } = ensureSession({ session_uuid, context: 'catalog', ip });
+ const history = loadHistory(sessionId);
+ logMessage(sessionId, 'user', message);
+
+ const assistantText = await ollamaChat({ system: SYS_CATALOG, history, message });
+ const tool = extractToolCall(assistantText);
+ const actions = [];
+
+ if (tool && tool.tool === 'search') {
+ const results = toolSearchDesigns(tool.args || {});
+ logMessage(sessionId, 'assistant', assistantText, { tool_name: 'search', tool_args: tool.args, tool_result: { count: results.length } });
+ actions.push({ type: 'search_results', count: results.length, designs: results });
+ } else if (tool && tool.tool === 'generate') {
+ logMessage(sessionId, 'assistant', assistantText, { tool_name: 'generate', tool_args: tool.args });
+ // Kick off async — for v1 we await it (~30s)
+ const gen = await toolGenerateDesign(tool.args, { chat_session_id: sessionId, request_text: message });
+ logMessage(sessionId, 'tool', 'generated', { tool_name: 'generate', tool_result: gen, generated_design_id: gen.id });
+ actions.push({ type: 'new_design', design: gen });
+ } else {
+ logMessage(sessionId, 'assistant', assistantText);
+ }
+ const replyText = (assistantText || '').replace(/```json[\s\S]*?```/g, '').trim();
+ res.json({ reply: replyText, session_uuid: uuid, actions });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+ });
+
+ // Per-design chat
+ app.post('/api/chat/design/:id', async (req, res) => {
+ try {
+ const id = parseInt(req.params.id, 10);
+ const { message, session_uuid } = req.body || {};
+ if (!message) return res.status(400).json({ error: 'message required' });
+ const designJson = psql(`SELECT row_to_json(t) FROM (SELECT id, category, dominant_hex, prompt FROM spoon_all_designs WHERE id=${id}) t;`);
+ if (!designJson) return res.status(404).json({ error: 'design_not_found' });
+ const d = JSON.parse(designJson);
+ const ip = (req.get('x-forwarded-for') || req.ip || '').split(',')[0].trim();
+ const { id: sessionId, session_uuid: uuid } = ensureSession({ session_uuid, context: `design:${id}`, ip });
+ const history = loadHistory(sessionId);
+ logMessage(sessionId, 'user', message);
+
+ const assistantText = await ollamaChat({ system: SYS_DESIGN(d), history, message });
+ const tool = extractToolCall(assistantText);
+ const actions = [];
+ if (tool && tool.tool === 'generate') {
+ logMessage(sessionId, 'assistant', assistantText, { tool_name: 'generate', tool_args: tool.args });
+ const gen = await toolGenerateDesign(tool.args, { chat_session_id: sessionId, parent_design_id: id, request_text: message });
+ logMessage(sessionId, 'tool', 'variant', { tool_name: 'generate', tool_result: gen, generated_design_id: gen.id });
+ actions.push({ type: 'variant', design: gen, parent_id: id });
+ } else {
+ logMessage(sessionId, 'assistant', assistantText);
+ }
+ const replyText = (assistantText || '').replace(/```json[\s\S]*?```/g, '').trim();
+ res.json({ reply: replyText, session_uuid: uuid, actions });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+ });
+
+ // Get session history (used by frontend to restore)
+ app.get('/api/chat/session/:uuid', (req, res) => {
+ try {
+ const row = psql(`SELECT row_to_json(t) FROM (SELECT id, context FROM wallco_chat_sessions WHERE session_uuid=${esc(req.params.uuid)}) t;`);
+ if (!row) return res.status(404).json({ error: 'not_found' });
+ const { id, context } = JSON.parse(row);
+ const msgs = JSON.parse(psql(`SELECT COALESCE(json_agg(t),'[]'::json) FROM (SELECT role, content, generated_design_id, created_at FROM wallco_chat_messages WHERE session_id=${id} AND role IN ('user','assistant') ORDER BY created_at) t;`) || '[]');
+ res.json({ session_uuid: req.params.uuid, context, messages: msgs });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+ });
+}
+
+module.exports = { mount };
← 225c77f spoonflower: 3-path auth (Chrome attach > cookies > password
·
back to Wallco Ai
·
tick 6: tileability scored on all 304 PD images · verdict: 6 78f6515 →