diff --git a/design/src/auth.ts b/design/src/auth.ts index a6bdc0cb43..614418ce97 100644 --- a/design/src/auth.ts +++ b/design/src/auth.ts @@ -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; @@ -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); } /** @@ -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); diff --git a/design/src/generate.ts b/design/src/generate.ts index a34b715187..85dd8dc5b1 100644 --- a/design/src/generate.ts +++ b/design/src/generate.ts @@ -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; @@ -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 { + 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, @@ -88,17 +213,47 @@ async function callImageGeneration( * Generate a single mockup from a brief. */ export async function generate(options: GenerateOptions): Promise { - 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++) { @@ -108,7 +263,20 @@ export async function generate(options: GenerateOptions): Promise { + 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"); + }); +});