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
38 changes: 31 additions & 7 deletions design/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@
* Resolution order:
* 1. ~/.gstack/openai.json → { "api_key": "sk-..." }
* 2. OPENAI_API_KEY environment variable
* 3. null (caller handles guided setup or fallback)
* 3. ~/.gstack/minimax.json → { "api_key": "sk-cp-..." }
* 4. null (caller handles guided setup or fallback)
*/

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

const CONFIG_PATH = path.join(process.env.HOME || "~", ".gstack", "openai.json");
const OPENAI_CONFIG_PATH = path.join(process.env.HOME || "~", ".gstack", "openai.json");
const MINIMAX_CONFIG_PATH = path.join(process.env.HOME || "~", ".gstack", "minimax.json");

export function resolveApiKey(): string | null {
// 1. Check ~/.gstack/openai.json
try {
if (fs.existsSync(CONFIG_PATH)) {
const content = fs.readFileSync(CONFIG_PATH, "utf-8");
if (fs.existsSync(OPENAI_CONFIG_PATH)) {
const content = fs.readFileSync(OPENAI_CONFIG_PATH, "utf-8");
const config = JSON.parse(content);
if (config.api_key && typeof config.api_key === "string") {
return config.api_key;
Expand All @@ -34,14 +36,35 @@ export function resolveApiKey(): string | null {
return null;
}

/**
* Check if MINIMAX_API_KEY is available (for MiniMax image generation).
*/
export function resolveMiniMaxApiKey(): string | null {
if (process.env.MINIMAX_API_KEY) {
return process.env.MINIMAX_API_KEY;
}
try {
if (fs.existsSync(MINIMAX_CONFIG_PATH)) {
const content = fs.readFileSync(MINIMAX_CONFIG_PATH, "utf-8");
const config = JSON.parse(content);
if (config.api_key && typeof config.api_key === "string") {
return config.api_key;
}
}
} catch {
// ignore
}
return null;
}

/**
* Save an API key to ~/.gstack/openai.json with 0600 permissions.
*/
export function saveApiKey(key: string): void {
const dir = path.dirname(CONFIG_PATH);
const dir = path.dirname(OPENAI_CONFIG_PATH);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ api_key: key }, null, 2));
fs.chmodSync(CONFIG_PATH, 0o600);
fs.writeFileSync(OPENAI_CONFIG_PATH, JSON.stringify({ api_key: key }, null, 2));
fs.chmodSync(OPENAI_CONFIG_PATH, 0o600);
}

/**
Expand All @@ -55,6 +78,7 @@ export function requireApiKey(): string {
console.error("Run: $D setup");
console.error(" or save to ~/.gstack/openai.json: { \"api_key\": \"sk-...\" }");
console.error(" or set OPENAI_API_KEY environment variable");
console.error(" or save to ~/.gstack/minimax.json: { \"api_key\": \"sk-cp-...\" }");
console.error("");
console.error("Get a key at: https://platform.openai.com/api-keys");
process.exit(1);
Expand Down
185 changes: 177 additions & 8 deletions design/src/generate.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
/**
* Generate UI mockups via OpenAI Responses API with image_generation tool.
* Generate UI mockups via OpenAI (gpt-4o) or MiniMax (image-01).
* OpenAI is tried first; falls back to MiniMax if OpenAI is unavailable or fails.
* Both can be configured via ~/.gstack/
*/

import fs from "fs";
import path from "path";
import { requireApiKey } from "./auth";
import { resolveApiKey, resolveMiniMaxApiKey } from "./auth";
import { parseBrief } from "./brief";
import { createSession, sessionPath } from "./session";
import { checkMockup } from "./check";

const MINIMAX_API_HOST = process.env.MINIMAX_API_HOST || "https://api.minimaxi.com";

export interface GenerateOptions {
brief?: string;
briefFile?: string;
Expand All @@ -26,11 +30,132 @@ export interface GenerateResult {
checkResult?: { pass: boolean; issues: string };
}

function gcd(a: number, b: number): number {
return b === 0 ? a : gcd(b, a % b);
}

export function normalizeSizeForProvider(
size: string | undefined,
provider: "openai" | "minimax",
): string {
if (!size) {
return provider === "minimax" ? "9:16" : "1536x1024";
}

const normalized = size.trim().toLowerCase();

if (provider === "minimax") {
const dimensions = normalized.match(/^(\d+)x(\d+)$/);
if (!dimensions) {
return normalized;
}

const width = Number(dimensions[1]);
const height = Number(dimensions[2]);
const divisor = gcd(width, height);
return `${width / divisor}:${height / divisor}`;
}

const ratio = normalized.match(/^(\d+):(\d+)$/);
if (!ratio) {
return normalized;
}

const width = Number(ratio[1]);
const height = Number(ratio[2]);

if (width === height) {
return "1024x1024";
}

return width > height ? "1536x1024" : "1024x1536";
}

/**
* Probe a provider by making a lightweight actual API call to verify the key works.
* Returns true if the call succeeds, false otherwise.
*/
async function probeProvider(
apiKey: string,
provider: "openai" | "minimax",
size: string,
): Promise<boolean> {
const probePrompt = "A simple red circle on white background";
try {
if (provider === "openai") {
await callOpenAIImageGeneration(apiKey, probePrompt, size, "medium");
} else {
await callMiniMaxImageGeneration(apiKey, probePrompt, size, "medium");
}
return true;
} catch {
return false;
}
}

/**
* Call MiniMax Image API (image-01).
* Returns base64 image data.
*/
async function callMiniMaxImageGeneration(
apiKey: string,
prompt: string,
size: string, // e.g. "9:16", "1:1", "16:9"
quality: string,
): Promise<{ responseId: string; imageData: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120_000);

try {
const response = await fetch(`${MINIMAX_API_HOST}/v1/image_generation`, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "image-01",
prompt: prompt,
aspect_ratio: size,
response_format: "base64",
n: 1,
quality: quality === "high" ? "high" : "medium",
}),
signal: controller.signal,
});

if (!response.ok) {
const error = await response.text();
throw new Error(`MiniMax API error (${response.status}): ${error}`);
}

const data = await response.json() as any;

const statusCode = data.base_resp?.status_code;
if (statusCode !== 0 && statusCode !== undefined) {
const msg = data.base_resp?.status_msg || "Unknown error";
throw new Error(`MiniMax API error (code ${statusCode}): ${msg}`);
}

const imageBase64 = data.data?.image_base64?.[0];
if (!imageBase64) {
throw new Error("No image data in MiniMax response");
}

return {
responseId: data.id || `minimax-${Date.now()}`,
imageData: imageBase64,
};
} finally {
clearTimeout(timeout);
}
}

/**
* Call OpenAI Responses API with image_generation tool.
* Returns the response ID and base64 image data.
*/
async function callImageGeneration(
async function callOpenAIImageGeneration(
apiKey: string,
prompt: string,
size: string,
Expand Down Expand Up @@ -88,17 +213,47 @@ async function callImageGeneration(
* Generate a single mockup from a brief.
*/
export async function generate(options: GenerateOptions): Promise<GenerateResult> {
const apiKey = requireApiKey();

// Parse the brief
const prompt = options.briefFile
? parseBrief(options.briefFile, true)
: parseBrief(options.brief!, false);

const size = options.size || "1536x1024";
const quality = options.quality || "high";
const maxRetries = options.retry ?? 0;

// Probe for available provider: OpenAI first, then MiniMax
const openAIKey = resolveApiKey();
const miniMaxKey = resolveMiniMaxApiKey();

let provider: "openai" | "minimax" | null = null;
let apiKey: string | null = null;

if (openAIKey) {
const openAISize = normalizeSizeForProvider(options.size, "openai");
console.error(`Probing OpenAI...`);
if (await probeProvider(openAIKey, "openai", openAISize)) {
provider = "openai";
apiKey = openAIKey;
}
}

if (!provider && miniMaxKey) {
const miniMaxSize = normalizeSizeForProvider(options.size, "minimax");
console.error(`Probing MiniMax...`);
if (await probeProvider(miniMaxKey, "minimax", miniMaxSize)) {
provider = "minimax";
apiKey = miniMaxKey;
}
}

if (!provider) {
console.error(`Image generation requires one of the following API keys:`);
console.error(`- OpenAI: ~/.gstack/openai.json → { "api_key": "sk-..." }`);
console.error(`- MiniMax: ~/.gstack/minimax.json → { "api_key": "sk-cp-..." }`);
console.error(`Run $D setup to configure.`);
process.exit(1);
}

let lastResult: GenerateResult | null = null;

for (let attempt = 0; attempt <= maxRetries; attempt++) {
Expand All @@ -108,7 +263,20 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult

// Generate the image
const startTime = Date.now();
const { responseId, imageData } = await callImageGeneration(apiKey, prompt, size, quality);
let responseId: string;
let imageData: string;
const size = normalizeSizeForProvider(options.size, provider);

if (provider === "minimax") {
const result = await callMiniMaxImageGeneration(apiKey!, prompt, size, quality);
responseId = result.responseId;
imageData = result.imageData;
} else {
const result = await callOpenAIImageGeneration(apiKey!, prompt, size, quality);
responseId = result.responseId;
imageData = result.imageData;
}

const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);

// Write to disk
Expand All @@ -120,7 +288,8 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult
// Create session
const session = createSession(responseId, prompt, options.output);

console.error(`Generated (${elapsed}s, ${(imageBuffer.length / 1024).toFixed(0)}KB) → ${options.output}`);
const model = provider === "minimax" ? "MiniMax-image-01" : "OpenAI-gpt-4o";
console.error(`Generated via ${model} (${elapsed}s, ${(imageBuffer.length / 1024).toFixed(0)}KB) → ${options.output}`);

lastResult = {
outputPath: options.output,
Expand Down
22 changes: 22 additions & 0 deletions test/design-generate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";

import { normalizeSizeForProvider } from "../design/src/generate";

describe("normalizeSizeForProvider", () => {
test("uses provider defaults when size is omitted", () => {
expect(normalizeSizeForProvider(undefined, "openai")).toBe("1536x1024");
expect(normalizeSizeForProvider(undefined, "minimax")).toBe("9:16");
});

test("converts OpenAI WxH sizes into MiniMax aspect ratios", () => {
expect(normalizeSizeForProvider("1536x1024", "minimax")).toBe("3:2");
expect(normalizeSizeForProvider("1024x1536", "minimax")).toBe("2:3");
expect(normalizeSizeForProvider("1024x1024", "minimax")).toBe("1:1");
});

test("converts aspect ratios into OpenAI-supported orientations", () => {
expect(normalizeSizeForProvider("16:9", "openai")).toBe("1536x1024");
expect(normalizeSizeForProvider("9:16", "openai")).toBe("1024x1536");
expect(normalizeSizeForProvider("1:1", "openai")).toBe("1024x1024");
});
});