feat: add multi-app support with app switcher, per-app branding, and filtered queries
Apps share the same backend, API keys, and publishing flow but each gets its own branding (name, colors, icon, app URL), knowledge files (brand identity, product info, platform guidelines), and campaigns. The pipeline dynamically writes _knowledge/ files and copies app assets before each run. - Add App model with slug, colors, appUrl, and knowledge markdown fields - Add appId FK to Campaign, seed honeyDue as first app with existing knowledge - App switcher dropdown in sidebar with icon previews - Filter campaigns, stats, and assets by active app (cookie-based) - De-hardcode lib/claude.ts: AppConfig interface, templated prompts, dynamic _knowledge/ and Remotion asset copying - App management pages (list, create, edit) with icon upload and color pickers - Asset library sort options (newest, oldest, name, platform, type) - Asset cards show creation date - Remotion HoneyDueAd accepts colors/appName props Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "./prisma";
|
||||
import type { App } from "./generated/prisma/client";
|
||||
|
||||
const COOKIE_NAME = "active-app";
|
||||
|
||||
export async function getActiveApp(): Promise<App | null> {
|
||||
const cookieStore = await cookies();
|
||||
const slug = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (slug) {
|
||||
const app = await prisma.app.findUnique({ where: { slug } });
|
||||
if (app) return app;
|
||||
}
|
||||
|
||||
// Default to first app
|
||||
return prisma.app.findFirst({ orderBy: { createdAt: "asc" } });
|
||||
}
|
||||
|
||||
export async function getActiveAppId(): Promise<string | null> {
|
||||
const app = await getActiveApp();
|
||||
return app?.id ?? null;
|
||||
}
|
||||
+108
-34
@@ -1,5 +1,5 @@
|
||||
import { spawn } from "child_process";
|
||||
import { mkdirSync } from "fs";
|
||||
import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { EventEmitter } from "events";
|
||||
import { prisma } from "./prisma";
|
||||
@@ -21,6 +21,18 @@ const AGENT_STEPS = [
|
||||
|
||||
export type AgentName = (typeof AGENT_STEPS)[number];
|
||||
|
||||
export interface AppConfig {
|
||||
name: string;
|
||||
slug: string;
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
darkBg: string;
|
||||
assetsDir: string;
|
||||
brandIdentity: string | null;
|
||||
productInfo: string | null;
|
||||
platformGuidelines: string | null;
|
||||
}
|
||||
|
||||
interface CampaignConfig {
|
||||
name: string;
|
||||
platforms: string[];
|
||||
@@ -38,15 +50,23 @@ interface CampaignConfig {
|
||||
/**
|
||||
* Build a detailed campaign prompt that gives each agent enough context.
|
||||
*/
|
||||
export function buildCampaignPrompt(config: CampaignConfig): string {
|
||||
export function buildCampaignPrompt(config: CampaignConfig, appConfig?: AppConfig): string {
|
||||
const platforms = config.platforms.join(", ");
|
||||
const variations = config.variations ?? 5;
|
||||
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const taskName = config.name.replace(/\s+/g, "_").toLowerCase();
|
||||
const outputDir = `outputs/${taskName}_${dateStr}`;
|
||||
|
||||
const appName = appConfig?.name ?? "honeyDue";
|
||||
const primaryColor = appConfig?.primaryColor ?? "#0079FF";
|
||||
const accentColor = appConfig?.accentColor ?? "#FF9400";
|
||||
const knowledgeDir = "_knowledge";
|
||||
|
||||
return `# Campaign Brief: "${config.name}"
|
||||
|
||||
## App
|
||||
${appName}
|
||||
|
||||
## Goal
|
||||
${config.goal === "app_downloads" ? "Drive app downloads" : config.goal === "brand_awareness" ? "Build brand awareness" : "Maximize engagement"}
|
||||
|
||||
@@ -62,9 +82,13 @@ ${config.visualDirection ? `## Visual Direction\n${config.visualDirection}` : ""
|
||||
${config.competitorApps ? `## Competitor Context\n${config.competitorApps}` : ""}
|
||||
${config.screenshots?.length ? `## App Screenshots\nThe user provided ${config.screenshots.length} app screenshot(s) to showcase in the ads. These are real screenshots of the app feature being promoted.\nScreenshot files:\n${config.screenshots.map((p) => `- ${p}`).join("\n")}\n\nIMPORTANT: The ad-creative-designer agent MUST incorporate these screenshots into the static ad layouts. Use them as the hero visual — frame them in a device mockup or place them prominently in the ad composition. The screenshots show the actual feature being advertised, so they should be the centerpiece of the ad creative, not the AI-generated imagery.` : ""}
|
||||
|
||||
## Brand Colors
|
||||
- Primary: ${primaryColor}
|
||||
- Accent: ${accentColor}
|
||||
|
||||
## Content Requirements
|
||||
- ${variations} hook variations per platform
|
||||
- Static ads at exact platform dimensions (see knowledge/platform_guidelines.md)
|
||||
- Static ads at exact platform dimensions (see ${knowledgeDir}/platform_guidelines.md)
|
||||
- Video ads with platform-appropriate styles:
|
||||
- Instagram: "polished" style — clean motion graphics
|
||||
- TikTok: "authentic" style — raw, native feel
|
||||
@@ -86,7 +110,7 @@ ${config.useTrendReport ? "## Use Latest Trends\nCheck outputs/ for the most rec
|
||||
|
||||
## Instructions
|
||||
Read CLAUDE.md first. Then execute each agent skill in order:
|
||||
1. Read knowledge files (brand_identity.md, platform_guidelines.md, product_campaign.md)
|
||||
1. Read knowledge files (${knowledgeDir}/brand_identity.md, ${knowledgeDir}/platform_guidelines.md, ${knowledgeDir}/product_campaign.md)
|
||||
2. Run trend-scout skill
|
||||
3. Run marketing-research-agent skill
|
||||
4. Run script-writer skill
|
||||
@@ -105,17 +129,24 @@ function buildAgentPrompt(
|
||||
agentName: string,
|
||||
campaignBrief: string,
|
||||
outputDir: string,
|
||||
appConfig?: AppConfig,
|
||||
screenshots?: string[]
|
||||
): string {
|
||||
const appName = appConfig?.name ?? "honeyDue";
|
||||
const primaryColor = appConfig?.primaryColor ?? "#0079FF";
|
||||
const accentColor = appConfig?.accentColor ?? "#FF9400";
|
||||
const assetsDir = appConfig?.assetsDir ?? "assets";
|
||||
const knowledgeDir = "_knowledge";
|
||||
|
||||
const agentInstructions: Record<string, string> = {
|
||||
"trend-scout": `You are the Trend Scout agent.
|
||||
|
||||
Read and follow the skill instructions in skills/trend-scout/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- knowledge/product_campaign.md
|
||||
- ${knowledgeDir}/brand_identity.md
|
||||
- ${knowledgeDir}/platform_guidelines.md
|
||||
- ${knowledgeDir}/product_campaign.md
|
||||
|
||||
Then execute the Tavily research queries described in the skill.
|
||||
Use the Bash tool to run: npx tavily search "your query here" OR write a Node.js script using @tavily/core.
|
||||
@@ -129,9 +160,9 @@ ${campaignBrief}`,
|
||||
Read and follow the skill instructions in skills/marketing-research-agent/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- knowledge/product_campaign.md
|
||||
- ${knowledgeDir}/brand_identity.md
|
||||
- ${knowledgeDir}/platform_guidelines.md
|
||||
- ${knowledgeDir}/product_campaign.md
|
||||
|
||||
Read the trend report from: ${outputDir}/trend_report.json (if it exists)
|
||||
|
||||
@@ -149,9 +180,9 @@ ${campaignBrief}`,
|
||||
Read and follow the skill instructions in skills/script-writer/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- knowledge/product_campaign.md
|
||||
- ${knowledgeDir}/brand_identity.md
|
||||
- ${knowledgeDir}/platform_guidelines.md
|
||||
- ${knowledgeDir}/product_campaign.md
|
||||
|
||||
Read the upstream outputs:
|
||||
- ${outputDir}/trend_report.json
|
||||
@@ -172,9 +203,9 @@ ${campaignBrief}`,
|
||||
Read and follow the skill instructions in skills/ad-creative-designer/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- knowledge/product_campaign.md
|
||||
- ${knowledgeDir}/brand_identity.md
|
||||
- ${knowledgeDir}/platform_guidelines.md
|
||||
- ${knowledgeDir}/product_campaign.md
|
||||
|
||||
Read the upstream outputs:
|
||||
- ${outputDir}/scripts/scripts_all.json
|
||||
@@ -191,16 +222,16 @@ Use the NanoBanana MCP tools to create polished ad images.${screenshots?.length
|
||||
CRITICAL — REFERENCE IMAGES:
|
||||
The user provided real app screenshots that MUST be used as reference images in every generate call.
|
||||
Screenshot absolute paths:
|
||||
${screenshots.map((p) => `- \${CWD}/${p}`).join("\n")}
|
||||
${screenshots.map((p) => `\${CWD}/${p}`).join("\n")}
|
||||
|
||||
Where \${CWD} is your current working directory (use pwd to get the absolute path).` : ""}
|
||||
|
||||
APP ICON: The honeyDue app icon is at assets/icon.png (honeycomb pattern with golden checkmark on dark navy). This icon MUST be visible in every ad — include it as a reference_image alongside the screenshot, and instruct Gemini to place the honeyDue icon prominently near the branding/CTA area of every ad.
|
||||
APP ICON: The ${appName} app icon is at ${assetsDir}/icon.png. This icon MUST be visible in every ad — include it as a reference_image alongside the screenshot, and instruct Gemini to place the ${appName} icon prominently near the branding/CTA area of every ad.
|
||||
|
||||
For EACH ad, follow this EXACT sequence:
|
||||
1. First call mcp__nanobanana__set_aspect_ratio to set the correct ratio for the platform (1:1 for feed, 9:16 for stories/reels/tiktok)
|
||||
2. Then call mcp__nanobanana__gemini_generate_image with:
|
||||
- prompt: A detailed description of the ad layout, headline text, brand colors (#0079FF blue, #FF9400 orange), and style${screenshots?.length ? `
|
||||
- prompt: A detailed description of the ad layout, headline text, brand colors (${primaryColor} blue, ${accentColor} orange), and style${screenshots?.length ? `
|
||||
- reference_images: ["/absolute/path/to/screenshot.png"] — use the REAL app screenshot so Gemini incorporates the actual UI, NOT a made-up version. The prompt should say "Use the provided reference image as the app screenshot shown on the phone screen in the ad. Do NOT change or recreate the app UI — use it exactly as provided."` : ""}
|
||||
- output_path: the destination file path
|
||||
3. Save to ${outputDir}/ads/gemini/
|
||||
@@ -230,8 +261,8 @@ The philosophy should:
|
||||
- Be 4-6 paragraphs articulating how the philosophy manifests through space, form, color, scale, rhythm, composition
|
||||
- Emphasize: visual expression over text, spatial communication, artistic interpretation, minimal words
|
||||
- Stress meticulous craftsmanship — the final work must appear as though someone at the top of their field labored over every detail
|
||||
- Draw from the campaign's soul: home maintenance as an act of care, the quiet anxiety of forgetting, the relief of being organized
|
||||
- Brand palette as foundation: #0079FF blue, #FF9400 orange, warm off-white, dark navy
|
||||
- Draw from the campaign's soul
|
||||
- Brand palette as foundation: ${primaryColor}, ${accentColor}, warm off-white, dark navy
|
||||
|
||||
### Step B: Express the Philosophy as Poster Art
|
||||
Using the philosophy, create each poster as a .png file. For each:
|
||||
@@ -240,10 +271,10 @@ Using the philosophy, create each poster as a .png file. For each:
|
||||
3. Use repeating patterns, perfect geometric shapes, systematic visual language
|
||||
4. Typography is minimal and design-forward — sparse labels, bold single phrases, never paragraphs
|
||||
5. The campaign's hook text appears as a visual accent, not a headline block
|
||||
6. ${screenshots?.length ? `Incorporate the app screenshot (${screenshots.join(", ")}) inside the phone frame image at assets/phone.png — layer the screenshot BEHIND the phone frame PNG (which has a transparent screen area and an orange rim with dynamic island). The screenshot and phone frame dynamic islands must align. This creates a realistic device mockup. Treat this composite as part of the art, not just dropped in.` : "Use abstract visual representations of the app concept"}
|
||||
6. ${screenshots?.length ? `Incorporate the app screenshot (${screenshots.join(", ")}) inside the phone frame image at ${assetsDir}/phone.png — layer the screenshot BEHIND the phone frame PNG (which has a transparent screen area and an orange rim with dynamic island). The screenshot and phone frame dynamic islands must align. This creates a realistic device mockup. Treat this composite as part of the art, not just dropped in.` : "Use abstract visual representations of the app concept"}
|
||||
7. Every element contained within canvas boundaries with proper margins — nothing overlaps, everything breathes
|
||||
8. The result should look like it could hang in a gallery or appear in a design magazine
|
||||
9. The honeyDue app icon (assets/icon.png — honeycomb with golden checkmark on dark navy) MUST appear in every poster, placed near the branding or CTA area. Use it as an <img> element in the HTML.
|
||||
9. The ${appName} app icon (${assetsDir}/icon.png) MUST appear in every poster, placed near the branding or CTA area. Use it as an <img> element in the HTML.
|
||||
|
||||
### MANDATORY Typography & Sizing Rules (Social Media Readability)
|
||||
These are viewed on phones at arm's length. Text that looks fine on a monitor is INVISIBLE in a feed.
|
||||
@@ -288,9 +319,9 @@ ${campaignBrief}`,
|
||||
Read and follow the skill instructions in skills/video-ad-producer/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- knowledge/product_campaign.md
|
||||
- ${knowledgeDir}/brand_identity.md
|
||||
- ${knowledgeDir}/platform_guidelines.md
|
||||
- ${knowledgeDir}/product_campaign.md
|
||||
|
||||
Read the upstream outputs:
|
||||
- ${outputDir}/scripts/scripts_all.json
|
||||
@@ -299,7 +330,7 @@ Read the upstream outputs:
|
||||
- ${outputDir}/ads/posters/manifest.json
|
||||
|
||||
## Phone Frame Asset
|
||||
A real iPhone frame PNG is at: assets/phone.png (orange rim, dynamic island, transparent screen)
|
||||
A real iPhone frame PNG is at: ${assetsDir}/phone.png (orange rim, dynamic island, transparent screen)
|
||||
Also available in remotion-ad/public/phone.png for use as a Remotion staticFile.
|
||||
${screenshots?.length ? `
|
||||
## App Screenshots
|
||||
@@ -338,9 +369,9 @@ ${campaignBrief}`,
|
||||
Read and follow the skill instructions in skills/copywriter-agent/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- knowledge/product_campaign.md
|
||||
- ${knowledgeDir}/brand_identity.md
|
||||
- ${knowledgeDir}/platform_guidelines.md
|
||||
- ${knowledgeDir}/product_campaign.md
|
||||
|
||||
Read the upstream outputs:
|
||||
- ${outputDir}/research_results.json
|
||||
@@ -362,8 +393,8 @@ ${campaignBrief}`,
|
||||
Read and follow the skill instructions in skills/distribution-agent/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- ${knowledgeDir}/brand_identity.md
|
||||
- ${knowledgeDir}/platform_guidelines.md
|
||||
|
||||
Gather ALL outputs from the campaign:
|
||||
- ${outputDir}/ads/ (all PNG files)
|
||||
@@ -526,10 +557,47 @@ async function loadPipelineEnv(): Promise<Record<string, string>> {
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the active app's knowledge files to pipeline/_knowledge/ for agents to read.
|
||||
*/
|
||||
function writeKnowledgeFiles(pipelineRoot: string, appConfig: AppConfig) {
|
||||
const knowledgeDir = path.join(pipelineRoot, "_knowledge");
|
||||
mkdirSync(knowledgeDir, { recursive: true });
|
||||
|
||||
if (appConfig.brandIdentity) {
|
||||
writeFileSync(path.join(knowledgeDir, "brand_identity.md"), appConfig.brandIdentity, "utf-8");
|
||||
}
|
||||
if (appConfig.productInfo) {
|
||||
writeFileSync(path.join(knowledgeDir, "product_campaign.md"), appConfig.productInfo, "utf-8");
|
||||
}
|
||||
if (appConfig.platformGuidelines) {
|
||||
writeFileSync(path.join(knowledgeDir, "platform_guidelines.md"), appConfig.platformGuidelines, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy app's icon and phone frame to remotion-ad/public/ for video rendering.
|
||||
*/
|
||||
function copyAppAssetsToRemotion(pipelineRoot: string, appConfig: AppConfig) {
|
||||
const remotionPublic = path.join(pipelineRoot, "remotion-ad", "public");
|
||||
mkdirSync(remotionPublic, { recursive: true });
|
||||
|
||||
const iconSrc = path.join(pipelineRoot, appConfig.assetsDir, "icon.png");
|
||||
const phoneSrc = path.join(pipelineRoot, appConfig.assetsDir, "phone.png");
|
||||
|
||||
if (existsSync(iconSrc)) {
|
||||
copyFileSync(iconSrc, path.join(remotionPublic, "icon.png"));
|
||||
}
|
||||
if (existsSync(phoneSrc)) {
|
||||
copyFileSync(phoneSrc, path.join(remotionPublic, "phone.png"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function launchPipeline(
|
||||
campaignId: string,
|
||||
prompt: string,
|
||||
cwd: string
|
||||
cwd: string,
|
||||
appConfig?: AppConfig
|
||||
) {
|
||||
// Load API keys from settings
|
||||
const pipelineEnv = await loadPipelineEnv();
|
||||
@@ -540,6 +608,12 @@ export async function launchPipeline(
|
||||
const campaignConfig = campaignData?.config ? JSON.parse(campaignData.config) : {};
|
||||
const screenshots: string[] = campaignConfig.screenshots || [];
|
||||
|
||||
// Write knowledge files and copy assets for the active app
|
||||
if (appConfig) {
|
||||
writeKnowledgeFiles(cwd, appConfig);
|
||||
copyAppAssetsToRemotion(cwd, appConfig);
|
||||
}
|
||||
|
||||
// Create output directories
|
||||
const dirs = ["ads", "scripts", "video", "copy"];
|
||||
for (const dir of dirs) {
|
||||
@@ -575,7 +649,7 @@ export async function launchPipeline(
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const agentPrompt = buildAgentPrompt(agentName, prompt, outputDir, screenshots);
|
||||
const agentPrompt = buildAgentPrompt(agentName, prompt, outputDir, appConfig, screenshots);
|
||||
const { output } = await runAgentStep(agentName, agentPrompt, cwd, pipelineEnv);
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user