← back to Wallco Ai
REFACTOR-2: extract lib/gemini.js wrapper for 6 Gemini call sites
550c260c8646aec6dc376bcc15ef4cc4f79b5464 · 2026-05-23 14:41:54 -0700 · Steve Abrams
Consolidates the duplicated fetch+key+logGemini boilerplate previously
copy-pasted across server.js — crop-fix, design-critique, ai-designer,
style-element-analysis, furnish, recolor.
lib/gemini.js exports:
geminiCall({ model, parts, generationConfig, app, note }) → response JSON
extractText(j) → first text part
extractImageData(j) → first inline_data/inlineData base64 string
extractJson(j) → text-part parsed as JSON (strips ```json fences)
Errors throw with .code = NO_KEY | HTTP_ERROR (+.status) | NETWORK.
Each call site maps those to its own response shape (callers preserved
their existing status codes + error message wording).
Behavior unchanged. Local boot test: /health → 200, 26709 designs loaded.
Files touched
A lib/gemini.jsM server.js
Diff
commit 550c260c8646aec6dc376bcc15ef4cc4f79b5464
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 23 14:41:54 2026 -0700
REFACTOR-2: extract lib/gemini.js wrapper for 6 Gemini call sites
Consolidates the duplicated fetch+key+logGemini boilerplate previously
copy-pasted across server.js — crop-fix, design-critique, ai-designer,
style-element-analysis, furnish, recolor.
lib/gemini.js exports:
geminiCall({ model, parts, generationConfig, app, note }) → response JSON
extractText(j) → first text part
extractImageData(j) → first inline_data/inlineData base64 string
extractJson(j) → text-part parsed as JSON (strips ```json fences)
Errors throw with .code = NO_KEY | HTTP_ERROR (+.status) | NETWORK.
Each call site maps those to its own response shape (callers preserved
their existing status codes + error message wording).
Behavior unchanged. Local boot test: /health → 200, 26709 designs loaded.
---
lib/gemini.js | 73 +++++++++++++++++
server.js | 252 ++++++++++++++++++++++------------------------------------
2 files changed, 170 insertions(+), 155 deletions(-)
diff --git a/lib/gemini.js b/lib/gemini.js
new file mode 100644
index 0000000..a6d05bb
--- /dev/null
+++ b/lib/gemini.js
@@ -0,0 +1,73 @@
+// Gemini API wrapper — consolidates the 3 scattered fetch+key+logGemini
+// call sites previously duplicated in server.js (crop-fix, design-critique,
+// ai-designer). Centralizes:
+// - key resolution (GOOGLE_API_KEY → GEMINI_API_KEY)
+// - URL build (one place to bump API version)
+// - cost-tracker logGemini accounting (was try/catch-wrapped at every site)
+// - response shape probing (handles both inlineData and inline_data)
+//
+// Errors throw with `.code` set: NO_KEY | HTTP_ERROR (+.status) | NETWORK.
+// Caller decides response status code from .code/.status.
+
+const path = require('path');
+const os = require('os');
+
+function geminiKey() {
+ return (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || '').trim();
+}
+
+async function geminiCall({ model, parts, generationConfig, app = 'wallco-ai', note = '' }) {
+ const key = geminiKey();
+ if (!key) {
+ const err = new Error('GEMINI_API_KEY not configured');
+ err.code = 'NO_KEY';
+ throw err;
+ }
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`;
+ const body = { contents: [{ parts }] };
+ if (generationConfig) body.generationConfig = generationConfig;
+
+ let r;
+ try {
+ r = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ } catch (e) {
+ const err = new Error('gemini network: ' + e.message);
+ err.code = 'NETWORK';
+ throw err;
+ }
+ if (!r.ok) {
+ const text = (await r.text()).slice(0, 240);
+ const err = new Error(`gemini http ${r.status}: ${text}`);
+ err.code = 'HTTP_ERROR';
+ err.status = r.status;
+ throw err;
+ }
+ const j = await r.json();
+ try {
+ const { logGemini } = require(path.join(os.homedir(), '.claude/skills/cost-tracker/scripts/log-gemini.js'));
+ logGemini(j, { app, model, note });
+ } catch { /* non-fatal */ }
+ return j;
+}
+
+function extractText(j) {
+ return j?.candidates?.[0]?.content?.parts?.find(p => p.text)?.text || null;
+}
+
+function extractImageData(j) {
+ const part = j?.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
+ return part?.inline_data?.data || part?.inlineData?.data || null;
+}
+
+function extractJson(j) {
+ const t = extractText(j);
+ if (!t) return null;
+ try { return JSON.parse(t.replace(/^```json\s*|\s*```$/g, '')); }
+ catch { return null; }
+}
+
+module.exports = { geminiKey, geminiCall, extractText, extractImageData, extractJson };
diff --git a/server.js b/server.js
index 54e4ff2..93ffce7 100644
--- a/server.js
+++ b/server.js
@@ -317,6 +317,7 @@ loadDesigns();
// REFACTOR-1d: parseDesignId / parseIdLike available via lib/parse.js for new code.
const { maybe304 } = require('./lib/http');
const { parseDesignId, parseIdLike } = require('./lib/parse');
+const { geminiCall, extractText: geminiText, extractImageData: geminiImage, extractJson: geminiJson } = require('./lib/gemini');
// ── Levenshtein distance (early-exit at cap). Used for did-you-mean suggestions.
function _lev(a, b, cap) {
@@ -6681,9 +6682,6 @@ app.post('/api/design/:id/crop-fix', express.json({ limit: '16kb' }), async (req
}
const fixKind = String(req.body?.fix_kind || 'ghost-layer');
- const GEMINI_KEY = process.env.GEMINI_API_KEY;
- if (!GEMINI_KEY) return res.status(500).json({ error: 'GEMINI_API_KEY not set' });
-
try {
// Load source row (PG → in-memory cache fallback)
let d = null;
@@ -6748,30 +6746,25 @@ print('OK', left, top, right, bottom)
);
const imageB64 = fs.readFileSync(composedPath).toString('base64');
- const body = {
- contents: [{ parts: [
- { inline_data: { mime_type: 'image/png', data: imageB64 } },
- { text: editPrompt },
- ]}],
- generationConfig: { responseModalities: ['IMAGE'] },
- };
- const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${GEMINI_KEY}`;
const t0 = Date.now();
- const r = await fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- if (!r.ok) return res.status(502).json({ error: 'gemini http ' + r.status + ': ' + (await r.text()).slice(0, 240) });
- const j = await r.json();
+ let j;
try {
- const { logGemini } = require(require('os').homedir() + '/.claude/skills/cost-tracker/scripts/log-gemini.js');
- logGemini(j, { app: 'wallco-ai', note: 'crop-fix-' + fixKind + '-from-' + id, model: 'gemini-2.5-flash-image' });
- } catch { /* non-fatal */ }
- const part = j.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
- const data = part?.inline_data?.data || part?.inlineData?.data;
+ j = await geminiCall({
+ model: 'gemini-2.5-flash-image',
+ parts: [
+ { inline_data: { mime_type: 'image/png', data: imageB64 } },
+ { text: editPrompt },
+ ],
+ generationConfig: { responseModalities: ['IMAGE'] },
+ note: 'crop-fix-' + fixKind + '-from-' + id,
+ });
+ } catch (e) {
+ if (e.code === 'NO_KEY') return res.status(500).json({ error: 'GEMINI_API_KEY not set' });
+ return res.status(e.status === 502 ? 502 : 502).json({ error: e.message });
+ }
+ const data = geminiImage(j);
if (!data) {
- const text = j.candidates?.[0]?.content?.parts?.find(p => p.text)?.text;
+ const text = geminiText(j);
return res.status(502).json({ error: 'gemini returned no image: ' + (text || '').slice(0, 240) });
}
@@ -14176,21 +14169,15 @@ app.post('/api/studio/interior-design-review', __reviewLimiter, async (req, res)
if (!d.id) return res.status(404).json({ error: 'design not found' });
// Gemini vision review (mandatory per /interior-designer guidance)
- const geminiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
let critique = { strengths: [], concerns: [], recommendation: '' };
- if (geminiKey && d.local_path && fs.existsSync(d.local_path)) {
+ if (d.local_path && fs.existsSync(d.local_path)) {
try {
const b64 = fs.readFileSync(d.local_path).toString('base64');
- const r = await fetch(
- `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`,
- {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- contents: [{
- parts: [
- { inlineData: { mimeType: 'image/jpeg', data: b64 } },
- { text: `You are a luxury interior designer reviewing a new wallpaper pattern.
+ const j = await geminiCall({
+ model: 'gemini-2.0-flash',
+ parts: [
+ { inlineData: { mimeType: 'image/jpeg', data: b64 } },
+ { text: `You are a luxury interior designer reviewing a new wallpaper pattern.
Original prompt: "${(prompt || d.prompt || '').slice(0,400)}"
Critique as a designer who places this in HIGH-END interiors (Hermès, Soho House, private hotel suites). STRICT JSON:
@@ -14201,23 +14188,16 @@ Critique as a designer who places this in HIGH-END interiors (Hermès, Soho Hous
"room_fit": ["<best room: bedroom|living|dining|powder|library|...>", "<second-best>"],
"lighting": "<best lighting: north light, candlelight, gallery, sunny>",
"scale": "<small repeat / medium / large / mural>"
-}` }
- ]
- }],
- generationConfig: { temperature: 0.2, maxOutputTokens: 600, responseMimeType: 'application/json' }
- })
- }
- );
- const j = await r.json();
- try {
- const { logGemini } = require(require('path').join(require('os').homedir(), '.claude/skills/cost-tracker/scripts/log-gemini.js'));
- logGemini(j, { app: 'wallco-ai', model: 'gemini-2.0-flash', note: 'design-critique ' + (d.id || '') });
- } catch {}
- const t = j.candidates?.[0]?.content?.parts?.[0]?.text;
- if (t) {
- try { critique = JSON.parse(t.replace(/^```json\s*|\s*```$/g, '')); } catch {}
- }
- } catch (e) { critique._err = e.message; }
+}` },
+ ],
+ generationConfig: { temperature: 0.2, maxOutputTokens: 600, responseMimeType: 'application/json' },
+ note: 'design-critique ' + (d.id || ''),
+ });
+ const parsed = geminiJson(j);
+ if (parsed) critique = parsed;
+ } catch (e) {
+ if (e.code !== 'NO_KEY') critique._err = e.message;
+ }
}
// SEC-5: scrub server-only fields before returning + persist to cache so
// subsequent visitors on the same design hit disk, not Gemini.
@@ -14251,20 +14231,14 @@ app.post('/api/design/:id/ai-designer', requireAdmin, express.json({ limit: '16k
return res.status(409).json({ error: 'image file missing on disk', local_path: d.local_path });
}
- const geminiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
- if (!geminiKey) return res.status(503).json({ error: 'GEMINI_API_KEY not configured' });
-
const b64 = fs.readFileSync(d.local_path).toString('base64');
- const r = await fetch(
- `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`,
- {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- contents: [{
- parts: [
- { inlineData: { mimeType: 'image/jpeg', data: b64 } },
- { text: `You are a luxury interior designer cataloging a new wallpaper pattern for Designer Wallcoverings.
+ let j;
+ try {
+ j = await geminiCall({
+ model: 'gemini-2.0-flash',
+ parts: [
+ { inlineData: { mimeType: 'image/jpeg', data: b64 } },
+ { text: `You are a luxury interior designer cataloging a new wallpaper pattern for Designer Wallcoverings.
Look at the image and answer with STRICT JSON only. The current generation prompt is provided as a HINT only — your authority is what you actually SEE in the image, not what the prompt requested. If the image contains a motif the prompt tried to remove (e.g. leaves, plants, banana, monstera, palm leaves, palm fronds), say so — accuracy matters.
Generation prompt hint: "${(d.prompt || '').slice(0, 500)}"
@@ -14279,25 +14253,20 @@ Return STRICT JSON:
"leaf_kind": "<if contains_leaves true: 'banana'|'monstera'|'palm'|'tropical'|'generic'; else 'none'>",
"seam_visible": <true|false — does the image show visible horizontal or vertical seam lines / tile cut-marks indicating bad tiling?>,
"designer_notes": "<one sentence the catalog can quote>"
-}` }
- ]
- }],
- generationConfig: { temperature: 0.2, maxOutputTokens: 500, responseMimeType: 'application/json' }
- })
- }
- );
- const j = await r.json();
- try {
- const { logGemini } = require(require('path').join(require('os').homedir(), '.claude/skills/cost-tracker/scripts/log-gemini.js'));
- logGemini(j, { app: 'wallco-ai', model: 'gemini-2.0-flash', note: 'ai-designer ' + id });
- } catch {}
- const t = j.candidates?.[0]?.content?.parts?.[0]?.text;
- let suggested = null;
- if (t) {
- try { suggested = JSON.parse(t.replace(/^```json\s*|\s*```$/g, '')); }
- catch { return res.status(502).json({ error: 'gemini JSON parse failed', raw: t.slice(0, 400) }); }
+}` },
+ ],
+ generationConfig: { temperature: 0.2, maxOutputTokens: 500, responseMimeType: 'application/json' },
+ note: 'ai-designer ' + id,
+ });
+ } catch (e) {
+ if (e.code === 'NO_KEY') return res.status(503).json({ error: 'GEMINI_API_KEY not configured' });
+ return res.status(502).json({ error: e.message });
+ }
+ const suggested = geminiJson(j);
+ if (!suggested) {
+ const t = geminiText(j);
+ return res.status(502).json({ error: t ? 'gemini JSON parse failed' : 'no candidate text from gemini', raw: (t || '').slice(0, 400) });
}
- if (!suggested) return res.status(502).json({ error: 'no candidate text from gemini' });
// Hard settlement gate — if Gemini reports leaves/banana, refuse to persist
if (suggested.contains_leaves === true && /^(banana|monstera|palm|tropical)$/i.test(suggested.leaf_kind || '')) {
@@ -14517,45 +14486,27 @@ print(f"#{r:02x}{g:02x}{b:02x}")
// AI style + element analysis via Gemini (mandatory per dw standing rule)
let ai = { style: null, colors: [], elements: [], summary: null };
- const geminiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
- if (geminiKey) {
- try {
- const b64 = fs.readFileSync(target).toString('base64');
- const r = await fetch(
- `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`,
- {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- contents: [{
- parts: [
- { inlineData: { mimeType: 'image/jpeg', data: b64 } },
- { text: `Analyze this design. Return STRICT JSON:
+ try {
+ const b64 = fs.readFileSync(target).toString('base64');
+ const j = await geminiCall({
+ model: 'gemini-2.0-flash',
+ parts: [
+ { inlineData: { mimeType: 'image/jpeg', data: b64 } },
+ { text: `Analyze this design. Return STRICT JSON:
{
"style": "<one of: Baroque, Rococo, Neoclassical, Art Nouveau, Art Deco, Arts and Crafts, Mid-century Modern, Bauhaus, Memphis, Maximalist, Minimalist, Chinoiserie, Toile, Damask, Ikat, Suzani, Moroccan, Japonisme, Folk Scandi, Tropical, Other>",
"colors": ["#hex","#hex","#hex"],
"elements": ["<element keywords like bird, peony, monogram, trellis, etc — max 8>"],
"summary": "<one sentence describing the design>"
-}` }
- ]
- }],
- generationConfig: { temperature: 0.1, maxOutputTokens: 400, responseMimeType: 'application/json' }
- })
- }
- );
- const j = await r.json();
- try {
- const { logGemini } = require(require('path').join(require('os').homedir(), '.claude/skills/cost-tracker/scripts/log-gemini.js'));
- logGemini(j, { app: 'wallco-ai', model: 'gemini-2.0-flash', note: 'style-element-analysis' });
- } catch {}
- const t = j.candidates && j.candidates[0] && j.candidates[0].content
- && j.candidates[0].content.parts && j.candidates[0].content.parts[0]
- && j.candidates[0].content.parts[0].text;
- if (t) {
- const cleaned = t.replace(/^```json\s*|\s*```$/g, '');
- try { ai = { ...ai, ...JSON.parse(cleaned) }; } catch {}
- }
- } catch (e) { console.error('gemini upload error:', e.message); }
+}` },
+ ],
+ generationConfig: { temperature: 0.1, maxOutputTokens: 400, responseMimeType: 'application/json' },
+ note: 'style-element-analysis',
+ });
+ const parsed = geminiJson(j);
+ if (parsed) ai = { ...ai, ...parsed };
+ } catch (e) {
+ if (e.code !== 'NO_KEY') console.error('gemini upload error:', e.message);
}
let stat = fs.statSync(target);
@@ -15695,9 +15646,6 @@ app.post('/api/design/:id/furnish', __furnishLimiter, async (req, res) => {
const d = DESIGNS.find(x => x.id === id);
if (!d) return res.status(404).json({ ok: false, error: 'design not found' });
- const KEY = process.env.GEMINI_API_KEY;
- if (!KEY) return res.status(500).json({ ok: false, error: 'GEMINI_API_KEY missing' });
-
const palette = Array.isArray(d.palette) ? d.palette.map(p => p.hex).slice(0, 5).join(', ') : '';
const motifs = Array.isArray(d.motifs) ? d.motifs.slice(0, 6).join(', ') : '';
const contextLabel = {
@@ -15717,23 +15665,23 @@ app.post('/api/design/:id/furnish', __furnishLimiter, async (req, res) => {
'Categories to include: Seating, Lighting, Rugs, Art, Tables, Accessories. 2-3 items each. Detail is 1 short phrase (material + color cue).',
].join(' ');
- const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${KEY}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- contents: [{ parts: [{ text: prompt }] }],
- generationConfig: { responseMimeType: 'application/json', temperature: 0.7 },
- }),
- });
- if (!r.ok) return res.status(502).json({ ok: false, error: 'gemini ' + r.status, body: (await r.text()).slice(0,200) });
- const j = await r.json();
+ let j;
try {
- const { logGemini } = require(require('os').homedir() + '/.claude/skills/cost-tracker/scripts/log-gemini.js');
- logGemini(j, { app: 'wallco-ai', note: 'furnish-' + ctx, model: 'gemini-2.5-flash' });
- } catch {}
- const text = j.candidates?.[0]?.content?.parts?.find(p => p.text)?.text || '{}';
- let parsed = {};
- try { parsed = JSON.parse(text); } catch (e) { return res.status(502).json({ ok: false, error: 'parse failed', text: text.slice(0,200) }); }
+ j = await geminiCall({
+ model: 'gemini-2.5-flash',
+ parts: [{ text: prompt }],
+ generationConfig: { responseMimeType: 'application/json', temperature: 0.7 },
+ note: 'furnish-' + ctx,
+ });
+ } catch (e) {
+ if (e.code === 'NO_KEY') return res.status(500).json({ ok: false, error: 'GEMINI_API_KEY missing' });
+ return res.status(502).json({ ok: false, error: e.message });
+ }
+ const parsed = geminiJson(j) || {};
+ if (!parsed.categories && !parsed.note) {
+ const text = geminiText(j) || '';
+ return res.status(502).json({ ok: false, error: 'parse failed', text: text.slice(0,200) });
+ }
const payload = { ok: true, design_id: id, context: ctx, categories: parsed.categories || [], note: parsed.note || '' };
// Write-through cache (best-effort; ignore disk errors)
try {
@@ -15780,9 +15728,6 @@ app.post('/api/design/:id/recolor', express.json(), async (req, res) => {
const hue = RECOLOR_HUES[hueKey];
if (!hue) return res.status(400).json({ ok: false, error: 'bad hue', valid: Object.keys(RECOLOR_HUES) });
- const KEY = process.env.GEMINI_API_KEY;
- if (!KEY) return res.status(500).json({ ok: false, error: 'GEMINI_API_KEY missing' });
-
// Load source image bytes from local_path or by reading the file behind image_url
const d = DESIGNS.find(x => x.id === id);
if (!d) return res.status(404).json({ ok: false, error: 'design not found' });
@@ -15798,22 +15743,19 @@ app.post('/api/design/:id/recolor', express.json(), async (req, res) => {
+ 'High detail, archival quality, fabric-pattern aesthetic.';
const t0 = Date.now();
- const gr = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${KEY}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- contents: [{ parts: [{ inline_data: { mime_type: 'image/png', data: srcB64 } }, { text: prompt }] }],
- generationConfig: { responseModalities: ['IMAGE'] },
- }),
- });
- if (!gr.ok) return res.status(502).json({ ok: false, error: 'gemini ' + gr.status, body: (await gr.text()).slice(0, 200) });
- const gj = await gr.json();
+ let gj;
try {
- const { logGemini } = require(require('os').homedir() + '/.claude/skills/cost-tracker/scripts/log-gemini.js');
- logGemini(gj, { app: 'wallco-ai', note: 'recolor-' + hueKey + '-from-' + id, model: 'gemini-2.5-flash-image' });
- } catch {}
- const part = gj.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
- const data = part?.inline_data?.data || part?.inlineData?.data;
+ gj = await geminiCall({
+ model: 'gemini-2.5-flash-image',
+ parts: [{ inline_data: { mime_type: 'image/png', data: srcB64 } }, { text: prompt }],
+ generationConfig: { responseModalities: ['IMAGE'] },
+ note: 'recolor-' + hueKey + '-from-' + id,
+ });
+ } catch (e) {
+ if (e.code === 'NO_KEY') return res.status(500).json({ ok: false, error: 'GEMINI_API_KEY missing' });
+ return res.status(502).json({ ok: false, error: e.message });
+ }
+ const data = geminiImage(gj);
if (!data) return res.status(502).json({ ok: false, error: 'no image returned', finish: gj.candidates?.[0]?.finishReason });
// Save PNG
← b940514 REFACTOR-1b/c/d: extract lib/color.js, lib/http.js, lib/pars
·
back to Wallco Ai
·
ghost-detector: 3x voting + maximal-determinism config 03cdc46 →