Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions design/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
/**
* Auth resolution for OpenAI API access.
* Auth resolution for API access.
*
* Resolution order:
* OpenAI resolution order:
* 1. ~/.gstack/openai.json → { "api_key": "sk-..." }
* 2. OPENAI_API_KEY environment variable
* 3. null (caller handles guided setup or fallback)
*
* Ark (Seedream) resolution order:
* 1. ~/.gstack/ark.json → { "api_key": "..." }
* 2. ARK_API_KEY environment variable
* 3. null
*/

import fs from "fs";
import path from "path";

const CONFIG_PATH = path.join(process.env.HOME || "~", ".gstack", "openai.json");
const GSTACK_DIR = path.join(process.env.HOME || "~", ".gstack");
const CONFIG_PATH = path.join(GSTACK_DIR, "openai.json");
const ARK_CONFIG_PATH = path.join(GSTACK_DIR, "ark.json");

export function resolveApiKey(): string | null {
// 1. Check ~/.gstack/openai.json
Expand Down Expand Up @@ -45,7 +52,7 @@ export function saveApiKey(key: string): void {
}

/**
* Get API key or exit with setup instructions.
* Get OpenAI API key or exit with setup instructions.
*/
export function requireApiKey(): string {
const key = resolveApiKey();
Expand All @@ -61,3 +68,38 @@ export function requireApiKey(): string {
}
return key;
}

// ---------------------------------------------------------------------------
// Ark (Volcengine) — used by Seedream provider
// ---------------------------------------------------------------------------

export function resolveArkApiKey(): string | null {
// 1. Check ~/.gstack/ark.json
try {
if (fs.existsSync(ARK_CONFIG_PATH)) {
const content = fs.readFileSync(ARK_CONFIG_PATH, "utf-8");
const config = JSON.parse(content);
if (config.api_key && typeof config.api_key === "string") {
return config.api_key;
}
}
} catch {
// Fall through to env var
}

// 2. Check environment variable
if (process.env.ARK_API_KEY) {
return process.env.ARK_API_KEY;
}

return null;
}

/**
* Save an Ark API key to ~/.gstack/ark.json with 0600 permissions.
*/
export function saveArkApiKey(key: string): void {
fs.mkdirSync(GSTACK_DIR, { recursive: true });
fs.writeFileSync(ARK_CONFIG_PATH, JSON.stringify({ api_key: key }, null, 2));
fs.chmodSync(ARK_CONFIG_PATH, 0o600);
}
11 changes: 10 additions & 1 deletion design/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { evolve } from "./evolve";
import { generateDesignToCodePrompt } from "./design-to-code";
import { serve } from "./serve";
import { gallery } from "./gallery";
import { resolveProvider } from "./providers";

function parseArgs(argv: string[]): { command: string; flags: Record<string, string | boolean> } {
const args = argv.slice(2); // skip bun/node and script path
Expand Down Expand Up @@ -60,7 +61,11 @@ function printUsage(): void {
console.log(` ${name.padEnd(12)} ${info.description}`);
console.log(` ${"".padEnd(12)} ${info.usage}`);
}
console.log("\nAuth: ~/.gstack/openai.json or OPENAI_API_KEY env var");
console.log("\nProviders: openai (default), seedream");
console.log(" --provider seedream or GSTACK_DESIGN_PROVIDER=seedream");
console.log("\nAuth:");
console.log(" OpenAI: ~/.gstack/openai.json or OPENAI_API_KEY env var");
console.log(" Seedream: ~/.gstack/ark.json or ARK_API_KEY env var");
console.log("Setup: $D setup");
}

Expand Down Expand Up @@ -125,6 +130,7 @@ async function main(): Promise<void> {
retry: flags.retry ? parseInt(flags.retry as string) : 0,
size: flags.size as string,
quality: flags.quality as string,
provider: resolveProvider(flags.provider as string),
});
break;

Expand Down Expand Up @@ -175,6 +181,7 @@ async function main(): Promise<void> {
size: flags.size as string,
quality: flags.quality as string,
viewports: flags.viewports as string,
provider: resolveProvider(flags.provider as string),
});
break;

Expand All @@ -183,6 +190,7 @@ async function main(): Promise<void> {
session: flags.session as string,
feedback: flags.feedback as string,
output: (flags.output as string) || "/tmp/gstack-iterate.png",
provider: resolveProvider(flags.provider as string),
});
break;

Expand Down Expand Up @@ -235,6 +243,7 @@ async function main(): Promise<void> {
screenshot: flags.screenshot as string,
brief: flags.brief as string,
output: (flags.output as string) || "/tmp/gstack-evolved.png",
provider: resolveProvider(flags.provider as string),
});
break;

Expand Down
16 changes: 8 additions & 8 deletions design/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,18 @@ export const COMMANDS = new Map<string, {
}>([
["generate", {
description: "Generate a UI mockup from a design brief",
usage: "generate --brief \"...\" --output /path.png",
flags: ["--brief", "--brief-file", "--output", "--check", "--retry", "--size", "--quality"],
usage: "generate --brief \"...\" --output /path.png [--provider seedream]",
flags: ["--brief", "--brief-file", "--output", "--check", "--retry", "--size", "--quality", "--provider"],
}],
["variants", {
description: "Generate N design variants from a brief",
usage: "variants --brief \"...\" --count 3 --output-dir /path/",
flags: ["--brief", "--brief-file", "--count", "--output-dir", "--size", "--quality", "--viewports"],
usage: "variants --brief \"...\" --count 3 --output-dir /path/ [--provider seedream]",
flags: ["--brief", "--brief-file", "--count", "--output-dir", "--size", "--quality", "--viewports", "--provider"],
}],
["iterate", {
description: "Iterate on an existing mockup with feedback",
usage: "iterate --session /path/session.json --feedback \"...\" --output /path.png",
flags: ["--session", "--feedback", "--output"],
usage: "iterate --session /path/session.json --feedback \"...\" --output /path.png [--provider seedream]",
flags: ["--session", "--feedback", "--output", "--provider"],
}],
["check", {
description: "Vision-based quality check on a mockup",
Expand All @@ -46,8 +46,8 @@ export const COMMANDS = new Map<string, {
}],
["evolve", {
description: "Generate improved mockup from existing screenshot",
usage: "evolve --screenshot current.png --brief \"make it calmer\" --output /path.png",
flags: ["--screenshot", "--brief", "--output"],
usage: "evolve --screenshot current.png --brief \"make it calmer\" --output /path.png [--provider seedream]",
flags: ["--screenshot", "--brief", "--output", "--provider"],
}],
["verify", {
description: "Compare live site screenshot against approved mockup",
Expand Down
95 changes: 32 additions & 63 deletions design/src/evolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,39 @@
* Takes a screenshot of the live site and generates a mockup showing
* how it SHOULD look based on a design brief.
* Starts from reality, not blank canvas.
*
* Vision analysis always uses OpenAI (GPT-4o). Image generation uses
* the selected provider (OpenAI or Seedream).
*/

import fs from "fs";
import path from "path";
import { requireApiKey } from "./auth";
import { type ProviderName, createProvider } from "./providers";

export interface EvolveOptions {
screenshot: string; // Path to current site screenshot
brief: string; // What to change ("make it calmer", "fix the hierarchy")
output: string; // Output path for evolved mockup
provider?: ProviderName;
}

/**
* Generate an evolved mockup from an existing screenshot + brief.
* Sends the screenshot as context to GPT-4o with image generation,
* asking it to produce a new version incorporating the brief's changes.
* Step 1: Analyze screenshot via GPT-4o vision (always OpenAI).
* Step 2: Generate new mockup via selected provider.
*/
export async function evolve(options: EvolveOptions): Promise<void> {
const apiKey = requireApiKey();
const providerName = options.provider || "openai";
const provider = createProvider(providerName);
const screenshotData = fs.readFileSync(options.screenshot).toString("base64");

console.error(`Evolving ${options.screenshot} with: "${options.brief}"`);
console.error(`Evolving ${options.screenshot} via ${providerName} with: "${options.brief}"`);
const startTime = Date.now();

// Use the Responses API with both a text prompt referencing the screenshot
// and the image_generation tool to produce the evolved version.
// Since we can't send reference images directly to image_generation,
// we describe the current state in detail first via vision, then generate.

// Step 1: Analyze current screenshot
const analysis = await analyzeScreenshot(apiKey, screenshotData);
// Step 1: Analyze current screenshot (always OpenAI vision)
const openaiKey = requireApiKey();
const analysis = await analyzeScreenshot(openaiKey, screenshotData);
console.error(` Analyzed current design: ${analysis.slice(0, 100)}...`);

// Step 2: Generate evolved version using analysis + brief
Expand All @@ -51,58 +53,25 @@ export async function evolve(options: EvolveOptions): Promise<void> {
"1536x1024 pixels.",
].join("\n");

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120_000);

try {
const response = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o",
input: evolvedPrompt,
tools: [{ type: "image_generation", size: "1536x1024", quality: "high" }],
}),
signal: controller.signal,
});

if (!response.ok) {
const error = await response.text();
if (response.status === 403 && error.includes("organization must be verified")) {
throw new Error(
"OpenAI organization verification required.\n"
+ "Go to https://platform.openai.com/settings/organization to verify.\n"
+ "After verification, wait up to 15 minutes for access to propagate.",
);
}
throw new Error(`API error (${response.status}): ${error.slice(0, 300)}`);
}

const data = await response.json() as any;
const imageItem = data.output?.find((item: any) => item.type === "image_generation_call");

if (!imageItem?.result) {
throw new Error("No image data in response");
}

fs.mkdirSync(path.dirname(options.output), { recursive: true });
const imageBuffer = Buffer.from(imageItem.result, "base64");
fs.writeFileSync(options.output, imageBuffer);

const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.error(`Generated (${elapsed}s, ${(imageBuffer.length / 1024).toFixed(0)}KB) → ${options.output}`);

console.log(JSON.stringify({
outputPath: options.output,
sourceScreenshot: options.screenshot,
brief: options.brief,
}, null, 2));
} finally {
clearTimeout(timeout);
}
const result = await provider.generateImage({
prompt: evolvedPrompt,
size: "1536x1024",
quality: "high",
});

fs.mkdirSync(path.dirname(options.output), { recursive: true });
const imageBuffer = Buffer.from(result.imageData, "base64");
fs.writeFileSync(options.output, imageBuffer);

const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.error(`Generated (${elapsed}s, ${(imageBuffer.length / 1024).toFixed(0)}KB) → ${options.output}`);

console.log(JSON.stringify({
outputPath: options.output,
sourceScreenshot: options.screenshot,
brief: options.brief,
provider: providerName,
}, null, 2));
}

/**
Expand Down
Loading