diff --git a/public/app.js b/public/app.js index e566407..dd1329e 100644 --- a/public/app.js +++ b/public/app.js @@ -145,6 +145,10 @@ async function loadSettings() { if (modelSel && data.nanoBananaModel) { modelSel.value = data.nanoBananaModel; } + const bgEl = document.getElementById("settings-user-background"); + if (bgEl && data.userBackground !== undefined) { + bgEl.value = data.userBackground; + } } catch (err) { console.warn("Failed to load settings:", err); } @@ -2141,7 +2145,11 @@ function bindGlobalEvents() { e.preventDefault(); const keyInput = document.getElementById("settings-api-key"); const modelSel = document.getElementById("settings-model-select"); - const payload = { nanoBananaModel: modelSel.value }; + const bgInput = document.getElementById("settings-user-background"); + const payload = { + nanoBananaModel: modelSel.value, + userBackground: bgInput ? bgInput.value.trim() : undefined + }; if (keyInput.value.trim()) { payload.geminiApiKey = keyInput.value.trim(); } @@ -2151,10 +2159,38 @@ function bindGlobalEvents() { }); keyInput.value = ""; document.getElementById("settings-modal")?.classList.add("hidden"); - showToast("🍌 Saved Nano Banana 2.1 settings!"); + showToast("✓ Saved SparkVault & AI Generator settings!"); await loadSettings(); }); + // Personalized Random Idea Button + document.getElementById("btn-generate-random-idea")?.addEventListener("click", async () => { + const btn = document.getElementById("btn-generate-random-idea"); + if (!btn) return; + const origText = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = "🎲 Generating..."; + showToast("🎲 Drawing a Whack Pack card & inventing an idea from your background..."); + + try { + const catSel = document.getElementById("quick-capture-category")?.value || "any"; + const res = await apiFetch("/api/ideas/generate-random", { + method: "POST", + body: JSON.stringify({ + category: catSel === "spark" ? "any" : catSel + }) + }); + await Promise.all([loadIdeas(), loadWhackCatalog()]); + showToast(`✨ Created "${res.idea.title}" (via Card #${res.card.number} • ${res.generatorUsed})!`); + openIdeaDrawer(res.idea.id, "details"); + } catch (err) { + showToast(`⚠️ ${err.message}`); + } finally { + btn.disabled = false; + btn.innerHTML = origText; + } + }); + // Lightbox Close document.getElementById("btn-close-lightbox")?.addEventListener("click", () => { document.getElementById("lightbox-modal")?.classList.add("hidden"); diff --git a/public/index.html b/public/index.html index 8bab07d..232283d 100644 --- a/public/index.html +++ b/public/index.html @@ -39,8 +39,16 @@ + diff --git a/server/idea_generator.js b/server/idea_generator.js new file mode 100644 index 0000000..6a770b5 --- /dev/null +++ b/server/idea_generator.js @@ -0,0 +1,312 @@ +import { spawn } from "node:child_process"; +import { GoogleGenAI } from "@google/genai"; +import { drawCards } from "./whack_cards.js"; +import { isCodexConfigured } from "./nano_banana.js"; + +export const DEFAULT_USER_BACKGROUND = `Creative technologist, homelabber, and maker (vloser.us). +Interests & Background: +- Vintage screenprint apparel, heavyweight crewneck tees (5-inch left-chest emblems & full-back prints), die-cut vinyl stickers, national park & alpine ski/mountain aesthetics, specialty espresso & craft brewing culture. +- Self-hosted Docker homelab infrastructure (Traefik, Gitea, Homepage, SQLite, AI agents, local automation, media & art tools). +- Tactile hardware & ambient displays (ESP32, E-Ink dashboards, home telemetry, vinyl/audio archives, photography & curated digital art galleries). +- Lateral thinking, Roger von Oech's Creative Whack Pack, and playful weekend builds that ship fast.`; + +export const CURATED_IDEA_TEMPLATES = [ + { + title: "Alpine Telemetry Club — Heavyweight Left-Chest & Back Print Tee", + elevator_pitch: "Vintage 1970s mountain weather-station tee featuring a 5-inch left-chest anemometer badge and a full-back topographic isobar map.", + category: "creative", + tags: ["tshirt", "merch", "screenprint", "alpine", "weather"], + rating_excitement: 5, + rating_impact: 4, + rating_ease: 5, + notes_markdown: "### Apparel & Print Concept\n- **Garment**: 6.5oz Heavyweight Vintage Cream (`#F5E8D3`) or Forest Pine (`#1E3F2B`) crewneck.\n- **Front Print**: 5-inch left-chest circular patch reading `HIGH ALTITUDE TELEMETRY DIVISION • EST. 1978`.\n- **Back Print**: 3-color spot screenprint showing elevation contour lines, barometric pressure curves, and a retro satellite dish icon.", + checklist: [ + "Mock up 5-inch left-chest emblem in Visual Mockup Studio", + "Test Burnt Orange + Espresso Brown + Gold ink palette on Cream tee", + "Export print-ready high-res PNG" + ], + pros: ["Bridges geeky homelab telemetry with classic outdoor heritage apparel", "Works great as a 3-color screenprint"], + cons: ["Fine isobar contour lines need careful halftone/stroke weight"] + }, + { + title: "Self-Hosted Homelab Rack Patch & Die-Cut Sticker Series", + elevator_pitch: "Collectible merit-badge style vinyl stickers and embroidered patches for homelab milestones ('Zero Downtime Club', 'DNS Survivor', 'Docker Compose Alchemist').", + category: "creative", + tags: ["sticker", "merch", "homelab", "docker", "illustration"], + rating_excitement: 5, + rating_impact: 4, + rating_ease: 5, + notes_markdown: "### Series Concept\n- Design a set of 4 vintage scout/national-park style badges celebrating self-hosting and homelab triumphs:\n 1. **It Was DNS All Along** (owl holding a tangled ethernet cable)\n 2. **Container Ship Captain** (vintage tugboat carrying glowing Docker containers)\n 3. **3-2-1 Backup Ranger** (forest lookout tower with tape reels)\n- Great for laptop lids, server rack doors, or left-chest pocket tees.", + checklist: [ + "Generate 'It Was DNS All Along' die-cut sticker mockup", + "Test white vinyl contour border on dark slate surface", + "Try left-chest t-shirt variation" + ], + pros: ["Instant hit with self-hosting/homelab community", "Low unit cost to print die-cut vinyl runs"], + cons: ["Need crisp bold typography that reads well at 3-inch sticker scale"] + }, + { + title: "Sub-Zero Cold Brew & Vinyl Listening Society Crewneck", + elevator_pitch: "Retro Japanese jazz-kissa (listening bar) inspired t-shirt combining a spinning 45rpm record adapter with an iced pour-over carafe.", + category: "creative", + tags: ["tshirt", "merch", "coffee", "vinyl", "retro"], + rating_excitement: 4, + rating_impact: 4, + rating_ease: 5, + notes_markdown: "### Design Notes\n- Inspired by Tokyo kissaten hi-fi coffee lounges.\n- **Colorway**: Midnight Navy (`#1B2A4A`) tee with cream and warm amber puff/plastisol ink.\n- **Graphic**: A geometric 45rpm vinyl spindle morphing into ripples of espresso in a ceramic mug.", + checklist: [ + "Generate Midnight Navy t-shirt mockup in Studio", + "Experiment with vintage mono-spaced typography in Canvas Compositor" + ], + pros: ["Combines two tactile daily rituals (coffee + music)", "Minimalist 2-color screenprint"], + cons: ["Dark garment requires opaque cream underbase"] + }, + { + title: "Homelab Service Health Receipt Printer (Thermal Desk Totem)", + elevator_pitch: "A retro POS thermal receipt printer on your desk that prints a physical morning 'Daily Dispatch' ticket with server stats, weather, and 1 random SparkVault idea + Whack Pack card.", + category: "project", + tags: ["hardware", "homelab", "thermal-printer", "automation", "esp32"], + rating_excitement: 5, + rating_impact: 5, + rating_ease: 4, + notes_markdown: "### Morning Ritual Hardware\n- Uses a $25 ESC/POS USB or Wi-Fi 58mm thermal receipt printer.\n- Every morning at 7:30 AM (or when a big arcade button is pressed), it queries `sparkvault.vloser.us/api/whack-cards/draw?mode=collider` and `vloser.us` container status.\n- Prints a crisp tear-off physical ticket with:\n - Today's Creative Whack Pack Card & prompt question\n - One stale Spark from your vault to think about over coffee\n - Homelab uptime summary", + checklist: [ + "Write Node/Python ESC/POS formatter script", + "Hook up `/api/whack-cards/draw?mode=collider` endpoint", + "Design dithered 1-bit header logo for the thermal roll" + ], + pros: ["Zero-ink thermal paper is pennies per roll", "Brings digital ideas into the physical world every morning"], + cons: ["Thermal paper fades if left in direct sunlight"] + }, + { + title: "AI Frame Curator — Dynamic Art & Mockup Rotator for Wall Display", + elevator_pitch: "Self-hosted web frame mode that turns any tablet or wall TV into a curated gallery cycling through your best art, photography, and active SparkVault mockups with QR codes to vote/comment.", + category: "project", + tags: ["art-gallery", "homelab", "docker", "display", "ui"], + rating_excitement: 4, + rating_impact: 4, + rating_ease: 4, + notes_markdown: "### Architecture\n- Lightweight kiosk view with museum-style matte framing, subtle ambient color matching, and museum placard metadata in the corner.\n- Guests or family can scan a tiny corner QR code on the LAN to give a 🔥 vote or drop a note directly into SparkVault.", + checklist: [ + "Mock up the gallery kiosk UI in Mockup Studio (`app-ui` preset)", + "Add museum placard typography overlay", + "Deploy behind Traefik on `vloser.us`" + ], + pros: ["Showcases art & t-shirt mockups passively around the house", "Uses existing homelab stack"], + cons: ["Needs wake-lock / auto-dimming schedule for night hours"] + }, + { + title: "Midnight Maintenance Crew — Glow-in-the-Dark Homelab Tee", + elevator_pitch: "Vintage utility-worker style t-shirt for late-night server tinkerers: 'MIDNIGHT MAINTENANCE CREW • VLOSER LABS • IF IT AIN'T BROKE, UPGRADE IT'.", + category: "creative", + tags: ["tshirt", "merch", "homelab", "screenprint", "vintage"], + rating_excitement: 5, + rating_impact: 4, + rating_ease: 5, + notes_markdown: "### Garment & Ink\n- **Garment**: Vintage Black (`#1C1B1A`) heavyweight tee.\n- **Graphic**: Retro mascot of a raccoon wearing a headlamp holding a wrench and a glowing fiber-optic patch cable.\n- **Placement**: 5-inch left-chest badge + oversized vintage back graphic.", + checklist: [ + "Generate raccoon mascot screenprint graphic in Mockup Studio", + "Test both Left-Chest and Full-Front placements" + ], + pros: ["Relatable self-deprecating homelab humor", "Looks awesome on Vintage Black or Heather Grey"], + cons: ["Multi-color mascot illustration benefits from DTG or simulated process screenprint"] + } +]; + +function extractJsonObject(rawText) { + if (!rawText) return null; + const cleaned = String(rawText) + .replace(/```json/gi, "") + .replace(/```/g, "") + .trim(); + const match = cleaned.match(/\{[\s\S]*\}/); + if (!match) return null; + try { + return JSON.parse(match[0]); + } catch { + return null; + } +} + +async function generateIdeaViaCodex(prompt) { + const { stdout, exitCode } = await new Promise((resolve, reject) => { + const child = spawn( + "codex", + [ + "exec", + "-m", + process.env.CODEX_MODEL || "gpt-5.6-sol", + "--skip-git-repo-check", + "--json", + prompt + ], + { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env } + } + ); + let out = ""; + child.stdout.on("data", (d) => { + out += d.toString(); + }); + child.on("error", reject); + child.on("close", (code) => resolve({ stdout: out, exitCode: code })); + }); + + let lastAgentText = ""; + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) continue; + try { + const evt = JSON.parse(trimmed); + if (evt.type === "item.completed" && evt.item?.type === "agent_message") { + lastAgentText = evt.item.text; + } + } catch { + // ignore + } + } + + const parsed = extractJsonObject(lastAgentText); + if (!parsed || !parsed.title) { + throw new Error(`Codex exited (${exitCode}) without returning valid idea JSON.`); + } + return parsed; +} + +async function generateIdeaViaGemini(apiKey, prompt) { + const ai = new GoogleGenAI({ apiKey }); + const interaction = await ai.interactions.create({ + model: "gemini-3.1-flash-lite", + input: prompt + }); + const parsed = extractJsonObject(interaction.output_text); + if (!parsed || !parsed.title) { + throw new Error("Gemini completed without returning valid idea JSON."); + } + return parsed; +} + +/** + * Generates a fresh, personalized idea tailored to the user's background, + * existing vault themes, and a randomly drawn Creative Whack Pack card. + */ +export async function generatePersonalizedRandomIdea({ + userBackground = DEFAULT_USER_BACKGROUND, + categoryFocus = "any", + existingTitles = [], + geminiApiKey = "" +}) { + const card = drawCards({ count: 1 })[0]; + + const categoryInstruction = + categoryFocus && categoryFocus !== "any" + ? `Focus specifically on the "${categoryFocus}" category (${ + categoryFocus === "creative" + ? "a graphic t-shirt, apparel, sticker, poster, or tactile merch design" + : categoryFocus === "project" + ? "a self-hosted app, homelab tool, hardware/E-Ink build, or creative utility" + : categoryFocus + }).` + : "Pick either a standout graphic t-shirt/merch concept ('creative') OR a clever homelab/hardware/creative-tech project ('project')."; + + const prompt = [ + "You are an imaginative creative partner generating a fresh, specific, high-craft idea for a maker's SparkVault.", + `USER BACKGROUND & TASTES:\n${userBackground || DEFAULT_USER_BACKGROUND}`, + `CREATIVE WHACK PACK CATALYST CARD DRAWN:\nCard #${card.number} (${card.suitLabel}): "${card.title}" — ${card.promptQuestion}`, + existingTitles.length > 0 + ? `EXISTING IDEAS IN VAULT (do NOT repeat these, invent something distinctly new):\n- ${existingTitles.slice(0, 20).join("\n- ")}` + : "", + categoryInstruction, + " Do NOT run any shell commands or tools. Reply ONLY with a single raw JSON object (no markdown fences) matching this exact schema:", + JSON.stringify({ + title: "Catchy, specific idea title", + elevator_pitch: "Compelling one-sentence pitch explaining what makes it awesome", + category: "creative or project or research or spark", + tags: ["tag1", "tag2", "tag3"], + notes_markdown: "### Concept & Specs\n- Detailed markdown notes (if apparel: blank color, ink palette, 5-inch left-chest vs full-front; if project: stack, hardware/UI, and why it's fun)", + checklist: ["Concrete step 1", "Concrete step 2", "Concrete step 3"], + pros: ["Pro 1", "Pro 2"], + cons: ["Constraint or risk 1"], + whack_reflection: `How Card #${card.number} (${card.title}) shaped this idea` + }) + ] + .filter(Boolean) + .join("\n\n"); + + let rawIdea = null; + let generatorUsed = "Curated Personalized Generator"; + + // 1. Try OpenAI Codex (if authenticated) + if (isCodexConfigured()) { + try { + rawIdea = await generateIdeaViaCodex(prompt); + generatorUsed = "OpenAI Codex (gpt-5.6-sol)"; + } catch (err) { + console.warn("Codex idea generation fallback:", err.message); + } + } + + // 2. Try Gemini 3.1 Flash-Lite (works on Free Tier!) + if (!rawIdea && geminiApiKey) { + try { + rawIdea = await generateIdeaViaGemini(geminiApiKey, prompt); + generatorUsed = "Gemini 3.1 Flash-Lite"; + } catch (err) { + console.warn("Gemini idea generation fallback:", err.message); + } + } + + // 3. Curated fallback remix if offline / no AI provider + if (!rawIdea) { + const unused = CURATED_IDEA_TEMPLATES.filter( + (t) => !existingTitles.some((ex) => ex.toLowerCase() === t.title.toLowerCase()) + ); + const pool = unused.length > 0 ? unused : CURATED_IDEA_TEMPLATES; + const chosen = pool[Math.floor(Math.random() * pool.length)]; + rawIdea = { + ...chosen, + whack_reflection: `Sparked with Creative Whack Pack Card #${card.number} (${card.title}): ${card.promptQuestion}` + }; + } + + return { + card, + generatorUsed, + ideaPayload: { + title: rawIdea.title || "Untitled Spark", + elevator_pitch: rawIdea.elevator_pitch || "", + category: ["creative", "project", "spark", "research"].includes(rawIdea.category) + ? rawIdea.category + : "creative", + status: "spark", + rating_excitement: 4, + rating_impact: 4, + rating_ease: 4, + inspired_by_card: card.number, + tags: Array.isArray(rawIdea.tags) ? rawIdea.tags : ["ai-spark"], + notes_markdown: rawIdea.notes_markdown || "", + checklist: (Array.isArray(rawIdea.checklist) ? rawIdea.checklist : []).map((txt, idx) => ({ + id: `chk-${Date.now()}-${idx}`, + text: typeof txt === "string" ? txt : txt.text || "", + done: false + })), + pros: Array.isArray(rawIdea.pros) ? rawIdea.pros : [], + cons: Array.isArray(rawIdea.cons) ? rawIdea.cons : [], + whack_history: [ + { + id: `whack-${Date.now()}`, + cardNumber: card.number, + cardTitle: card.title, + suit: card.suit, + suitLabel: card.suitLabel, + promptQuestion: card.promptQuestion, + reflection: + rawIdea.whack_reflection || + `Generated using Card #${card.number} (${card.title}) + personal background profile.`, + createdAt: new Date().toISOString() + } + ] + } + }; +} diff --git a/server/index.js b/server/index.js index 8a06c63..fad840f 100644 --- a/server/index.js +++ b/server/index.js @@ -17,6 +17,10 @@ import { generateWithNanoBanana, isCodexConfigured } from "./nano_banana.js"; +import { + DEFAULT_USER_BACKGROUND, + generatePersonalizedRandomIdea +} from "./idea_generator.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -180,6 +184,36 @@ export function createAppServer({ return; } + // 2b. POST /api/ideas/generate-random (Personalized Random Idea Generator) + if (method === "POST" && pathname === "/api/ideas/generate-random") { + const body = await readJsonBody(req); + const userBackground = + body.userBackground || + store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || + DEFAULT_USER_BACKGROUND; + const categoryFocus = body.category || "any"; + const existingTitles = store.listIdeas({}).map((i) => i.title); + const savedKey = store.getSetting("gemini_api_key", "") || process.env.GEMINI_API_KEY || ""; + + const result = await generatePersonalizedRandomIdea({ + userBackground, + categoryFocus, + existingTitles, + geminiApiKey: savedKey + }); + + const created = store.createIdea({ + ...result.ideaPayload, + preserveRawTitle: true + }); + + return sendJson(res, 201, { + idea: created, + card: result.card, + generatorUsed: result.generatorUsed + }); + } + // 3. GET / PATCH / DELETE /api/ideas/:id const ideaIdMatch = pathname.match(/^\/api\/ideas\/(\d+)$/); if (ideaIdMatch) { @@ -663,6 +697,7 @@ export function createAppServer({ ? "Authenticated via ~/.codex/auth.json" : "", nanoBananaModel: model, + userBackground: store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND, availableModels: NANO_BANANA_MODELS, surfacePresets: Object.entries(SURFACE_PRESETS).map(([id, val]) => ({ id, @@ -678,6 +713,9 @@ export function createAppServer({ if (body.nanoBananaModel !== undefined) { store.setSetting("nano_banana_model", String(body.nanoBananaModel).trim()); } + if (body.userBackground !== undefined) { + store.setSetting("user_background", String(body.userBackground).trim()); + } const savedKey = store.getSetting("gemini_api_key", ""); const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || ""; const activeKey = savedKey || envKey; @@ -686,7 +724,8 @@ export function createAppServer({ saved: true, hasApiKey: Boolean(activeKey) || codexReady, codexReady, - nanoBananaModel: store.getSetting("nano_banana_model", "codex-image-gen") + nanoBananaModel: store.getSetting("nano_banana_model", "codex-image-gen"), + userBackground: store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND }); } } diff --git a/test/api.test.js b/test/api.test.js index f890f11..4858948 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -216,3 +216,21 @@ test("External notes and images can be attached to a spark and promoted to Mocku assert.equal(promData.idea.mockups[0].title, "Vintage Ski Patch Photo"); }); +test("Personalized Random Idea Generator creates a tailored idea from background + Whack Pack card", async () => { + const res = await fetch(`${baseUrl}/api/ideas/generate-random`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + category: "creative" + }) + }); + assert.equal(res.status, 201); + const data = await res.json(); + assert.ok(data.idea && data.idea.id); + assert.ok(data.idea.title.length > 5); + assert.ok(data.card && data.card.number >= 1 && data.card.number <= 64); + assert.equal(data.idea.inspired_by_card, data.card.number); + assert.ok(data.idea.whack_history.length >= 1); +}); + +