Files
gstack/design/src/variants.ts
T
Garry Tanandcapy-ai[bot] 71acd83aa6 v1.92.11.0 feat: design mockups with Gemini when there's no OpenAI key (#990) (#3768)
* design: make images and read screenshots with Gemini when there is no OpenAI key (#990)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design: test the live Gemini check; set fixture env keys without secret-shaped assignments

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (setup keeps piped keys and names the provider export; Gemini iterate fallback tests)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* design: setup doc comment matches --key-stdin behavior

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: Gemini saves the final image of a thinking answer; iterate uploads only absolute-path PNGs under 15 MB; sessions store absolute output paths

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: fragment holds the entry text only

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* todos: Gemini design provider follow-ups

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: the egress fail-open list names design Gemini calls

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-01 05:17:06 +00:00

566 lines
23 KiB
TypeScript

/**
* Generate N design variants from a brief.
* Uses staggered parallel: 1s delay between API calls to avoid rate limits.
* Falls back to exponential backoff on 429s.
*/
import fs from "fs";
import path from "path";
import { requireProvider } from "./auth";
import { receiptedFetch } from "./receipted-fetch";
import { modelRejectionHint } from "./models";
import {
asProvider,
DesignImageError,
geminiFailure,
geminiSwitchFix,
imageRequest,
isOpenAiVerificationError,
readImage,
type DesignAuth,
type DesignRequest,
} from "./provider";
import { parseBrief } from "./brief";
import { normalizeIntFlag } from "./flag-utils";
import { checkMockup, type CheckResult } from "./check";
import { analyzeScreenshot, evolvePrompt } from "./evolve";
import {
emitResult,
exitCodeFor,
newAccounting,
persistImage,
type ExitCode,
type Recovery,
type RunAccounting,
} from "./persist";
export interface VariantsOptions {
brief?: string;
briefFile?: string;
/** JSON array of { brief, screenshot? }, one entry per variant. */
briefsFile?: string;
/**
* Raw CLI flag value or a number. Normalized inside variants() (#2032):
* nonsense errors loudly; above STYLE_VARIATIONS.length clamps with a
* warning — past that index variants degrade to duplicate base-brief runs.
*/
count?: number | string | boolean;
outputDir: string;
size?: string;
quality?: string;
viewports?: string; // "desktop,tablet,mobile" — generates at multiple sizes
}
const STYLE_VARIATIONS = [
"", // First variant uses the brief as-is
"Use a bolder, more dramatic visual style with stronger contrast and larger typography.",
"Use a calmer, more minimal style with generous whitespace and subtle colors.",
"Use a warmer, more approachable style with rounded corners and friendly typography.",
"Use a more professional, corporate style with sharp edges and structured grid layout.",
"Commit to one saturated hue across large surfaces (drenched color) with restrained decoration and texture from the product's material world.",
"Use a playful, modern style with asymmetric layout and unexpected color accents.",
];
export interface VariantResult {
requested: string;
path: string;
success: boolean;
error?: string;
recovered?: Recovery;
/** Exhausted its 429 retries. */
rateLimited?: boolean;
/** A rerun of the same operation could succeed (rate limit, timeout, deadline, 5xx, empty image). */
retryable?: boolean;
/** The API returned an empty image; nothing was saved. */
empty?: boolean;
}
type RequestFailure = { error: string; rateLimited?: boolean; retryable: boolean };
const DEADLINE_ERROR = "Batch deadline reached";
/** Resolves after `ms`, or as soon as `signal` aborts. */
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise(resolve => {
if (signal?.aborted) return resolve();
const done = () => { clearTimeout(timer); signal?.removeEventListener("abort", done); resolve(); };
const timer = setTimeout(done, ms);
signal?.addEventListener("abort", done, { once: true });
});
}
/**
* Request one variant image, retrying only the API call on 429. Returns the
* base64 image or the final error; nothing here touches the filesystem.
* `signal` (a batch deadline) stops backoff waits and the request; once it
* has aborted no new attempt starts.
*/
export async function requestVariantImage(
auth: DesignAuth,
prompt: string,
size: string,
quality: string,
fetchFn: typeof globalThis.fetch = globalThis.fetch,
signal?: AbortSignal,
): Promise<{ imageData: string } | RequestFailure> {
const maxRetries = 3;
const MAX_RETRY_AFTER_MS = 60_000; // cap honored Retry-After to bound stalls
let lastError = "";
let skipLeadingDelay = false;
const provider = asProvider(auth);
let request: DesignRequest;
try {
request = imageRequest(provider, { prompt, size, quality });
} catch (err: any) {
return { error: err.message, retryable: false };
}
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0 && !skipLeadingDelay) {
// Exponential backoff: 2s, 4s, 8s
const delay = Math.pow(2, attempt) * 1000;
console.error(` Rate limited, retrying in ${delay / 1000}s...`);
await sleep(delay, signal);
}
skipLeadingDelay = false;
if (signal?.aborted) return { error: DEADLINE_ERROR, retryable: true };
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 240_000);
let response: Response;
try {
response = await receiptedFetch("variants-image-request", request.url, {
...request.init,
signal: signal ? AbortSignal.any([controller.signal, signal]) : controller.signal,
}, fetchFn, request.receipt);
} catch (err: any) {
clearTimeout(timeout);
if (err.name === "AbortError") return { error: signal?.aborted ? DEADLINE_ERROR : "Timeout (240s)", retryable: true };
lastError = err.message;
continue;
}
clearTimeout(timeout);
if (response.status === 429) {
lastError = "Rate limited (429)";
const retryAfter = response.headers.get("retry-after");
let waitMs: number | null = null;
if (retryAfter) {
const trimmed = retryAfter.trim();
if (/^\d+$/.test(trimmed)) {
// delta-seconds (RFC 7231)
waitMs = Math.min(Number.parseInt(trimmed, 10) * 1000, MAX_RETRY_AFTER_MS);
} else {
// HTTP-date (RFC 7231)
const dateMs = Date.parse(trimmed);
if (!Number.isNaN(dateMs)) {
waitMs = Math.min(Math.max(0, dateMs - Date.now()), MAX_RETRY_AFTER_MS);
}
}
}
if (provider.provider === "gemini") {
// Gemini puts its delay in the body (RetryInfo), and a daily or zero quota never refills in time.
const failure = geminiFailure(provider, 429, await response.text(), "image");
if (!failure.retryable) return { error: failure.message, retryable: false };
if (failure.retryAfterMs !== undefined) waitMs = Math.min(failure.retryAfterMs, MAX_RETRY_AFTER_MS);
}
if (waitMs !== null) {
if (waitMs > 0) {
await sleep(waitMs, signal);
}
// Honored Retry-After (incl. 0 / past date "retry now") — skip the
// next iteration's leading exponential sleep so we don't double-wait.
skipLeadingDelay = true;
}
continue;
}
if (!response.ok) {
const error = await response.text();
if (provider.provider === "gemini") {
const failure = geminiFailure(provider, response.status, error, "image");
return { error: failure.message, retryable: failure.retryable };
}
if (isOpenAiVerificationError(response.status, error)) {
return { error: `OpenAI organization verification required. Go to https://platform.openai.com/settings/organization to verify.\n${geminiSwitchFix()}`, retryable: false };
}
return { error: `API error (${response.status}): ${error.slice(0, 200)}${modelRejectionHint(response.status, error, "image")}`, retryable: response.status >= 500 };
}
try {
const image = readImage(provider, await response.json());
if (!image) return { error: "No image data in response", retryable: true };
return { imageData: image.imageData };
} catch (err: any) {
if (err instanceof DesignImageError) return { error: err.message, retryable: err.retryable };
return { error: signal?.aborted ? DEADLINE_ERROR : `Unreadable API response: ${err.message}`, retryable: true };
}
}
return { error: lastError, rateLimited: lastError === "Rate limited (429)", retryable: true };
}
/**
* Generate a single variant: one API request (with 429 retry), then exactly
* one persistence attempt on the received bytes. A save failure is reported
* with its path and never triggers another request.
*
* Exported for testability. Pass `fetchFn` to inject a stubbed fetch in tests;
* production code uses the global fetch by default.
*/
export async function generateVariant(
apiKey: DesignAuth,
prompt: string,
outputPath: string,
size: string,
quality: string,
fetchFn: typeof globalThis.fetch = globalThis.fetch,
signal?: AbortSignal,
): Promise<VariantResult> {
const received = await requestVariantImage(apiKey, prompt, size, quality, fetchFn, signal);
if ("error" in received) {
return { requested: outputPath, path: outputPath, success: false, ...received };
}
const outcome = persistImage(received.imageData, outputPath);
if (outcome.ok) return { requested: outputPath, path: outcome.path, success: true };
return {
requested: outputPath,
path: outputPath,
success: false,
error: outcome.failure.reason,
...(Buffer.from(received.imageData || "", "base64").length === 0 ? { retryable: true, empty: true } : { retryable: false }),
...(outcome.recovered ? { recovered: outcome.recovered } : {}),
};
}
interface VariantJob {
outputPath: string;
prompt: string;
size: string;
label: string;
}
/** Launch jobs 1.5s apart, wait for all, and fold them into run accounting. */
async function runVariantJobs(apiKey: DesignAuth, quality: string, jobs: VariantJob[]): Promise<{
acct: RunAccounting;
errors: string[];
}> {
const promises = jobs.map((job, i) =>
new Promise(resolve => setTimeout(resolve, i * 1500)).then(() => {
console.error(` Starting ${job.label}...`);
return generateVariant(apiKey, job.prompt, job.outputPath, job.size, quality);
})
);
const results = await Promise.allSettled(promises);
const acct = newAccounting(jobs.length);
const errors: string[] = [];
results.forEach((result, i) => {
const requested = jobs[i].outputPath;
if (result.status === "fulfilled" && result.value.success) {
const size = fs.statSync(result.value.path).size;
console.error(` ✓ ${path.basename(result.value.path)} (${(size / 1024).toFixed(0)}KB)`);
acct.saved.push(result.value.path);
return;
}
const reason = result.status === "fulfilled" ? result.value.error || "unknown error" : (result.reason as Error).message;
console.error(` ✗ ${path.basename(requested)}: ${reason}`);
errors.push(path.basename(requested));
acct.failures.push({ file: requested, reason });
if (result.status === "fulfilled" && result.value.recovered) acct.recovered.push(result.value.recovered);
});
return { acct, errors };
}
function emitVariantsResult(outputDir: string, acct: RunAccounting, errors: string[], extra: Record<string, unknown> = {}, selected: string[] = acct.saved): ExitCode {
return emitResult({
outputDir,
...extra,
count: acct.requested,
succeeded: selected.length,
failed: errors.length,
paths: selected,
errors,
requested: acct.requested,
saved: acct.saved,
selected,
failures: acct.failures,
recovered: acct.recovered,
}, exitCodeFor(selected.length > 0, acct.saved.length));
}
export interface BriefEntry {
brief: string;
screenshot?: string;
}
/** One entry per variant; the ceiling matches the built-in style directions. */
export const MAX_BRIEFS = STYLE_VARIATIONS.length;
/** Whole-batch deadline; the skill's Bash call allows 600s. */
export const BATCH_DEADLINE_MS = 540_000;
const STAGGER_MS = 1500;
/**
* Read and validate a --briefs-file before any billable call. Errors name the
* file, the entry index and the field.
*/
export function readBriefsFile(file: string): BriefEntry[] {
const fail = (problem: string): never => { throw new Error(`--briefs-file ${file}: ${problem}`); };
let raw: unknown;
try {
raw = JSON.parse(fs.readFileSync(file, "utf-8"));
} catch (err: any) {
return fail(`cannot read a JSON array (${err.message})`);
}
if (!Array.isArray(raw) || raw.length < 1 || raw.length > MAX_BRIEFS) {
return fail(`expected a JSON array of 1 to ${MAX_BRIEFS} entries like [{"brief": "...", "screenshot": "optional.png"}]`);
}
return raw.map((entry: any, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) fail(`entry ${index}: expected an object with a "brief" field`);
const unknown = Object.keys(entry).find(key => key !== "brief" && key !== "screenshot");
if (unknown) fail(`entry ${index}: unknown field "${unknown}" (allowed: brief, screenshot)`);
if (typeof entry.brief !== "string" || !entry.brief.trim()) fail(`entry ${index}: field "brief" must be a non-empty string`);
if (entry.screenshot === undefined) return { brief: entry.brief };
if (typeof entry.screenshot !== "string" || !entry.screenshot) fail(`entry ${index}: field "screenshot" must be a non-empty string`);
let readable = false;
try {
fs.accessSync(entry.screenshot, fs.constants.R_OK);
readable = fs.statSync(entry.screenshot).isFile();
} catch {}
if (!readable) fail(`entry ${index}: field "screenshot" is not a readable file: ${entry.screenshot}`);
return { brief: entry.brief, screenshot: entry.screenshot };
});
}
export interface BatchVariant {
variant: string;
/** This variant's pick: its last saved image, or the requested name when nothing was saved. */
path: string;
/** Every image this variant saved, in order (a failed check's regeneration adds one). */
saved: string[];
operation: "generate" | "evolve";
status: "done" | "failed" | "rate_limited";
error: string | null;
retryable: boolean;
check: { status: CheckResult["status"]; issues: string } | null;
}
export interface BatchOptions {
apiKey: DesignAuth;
outputDir: string;
size: string;
quality: string;
fetchFn?: typeof globalThis.fetch;
deadlineMs?: number;
staggerMs?: number;
}
/**
* Generate one variant per brief with staggered parallel launches under one
* batch deadline. Every image is saved without overwriting. Each variant
* repeats its own operation (generate, or evolve when it has a screenshot)
* once for an empty image and once for a failed check, keeping both images,
* and always ends with a terminal status.
*/
export async function runBriefsBatch(entries: BriefEntry[], opts: BatchOptions, acct: RunAccounting = newAccounting(entries.length)): Promise<BatchVariant[]> {
const fetchFn = opts.fetchFn ?? globalThis.fetch;
const controller = new AbortController();
const deadline = setTimeout(() => controller.abort(), opts.deadlineMs ?? BATCH_DEADLINE_MS);
const signal = controller.signal;
const runOne = async (entry: BriefEntry, index: number): Promise<BatchVariant> => {
const variant = String.fromCharCode(65 + index);
const requested = path.join(opts.outputDir, `variant-${variant}.png`);
const operation = entry.screenshot ? "evolve" : "generate";
const saved: string[] = [];
let attempted = false;
const finish = (status: BatchVariant["status"], error: string | null, retryable: boolean, check: BatchVariant["check"]): BatchVariant => {
const pick = saved.at(-1) ?? requested;
if (error && !attempted) acct.failures.push({ file: requested, reason: error });
const detail = status === "done" ? `${saved.join(", ")} (check ${check!.status})` : error;
console.error(`VARIANT_${variant}_${status.toUpperCase()}: ${detail}`);
return { variant, path: pick, saved, operation, status, error, retryable, check };
};
const attempt = async (): Promise<VariantResult> => {
attempted = true;
let result: VariantResult;
if (!entry.screenshot) {
result = await generateVariant(opts.apiKey, entry.brief, requested, opts.size, opts.quality, fetchFn, signal);
} else {
let analysis: string | null = null;
try {
analysis = await analyzeScreenshot(opts.apiKey, fs.readFileSync(entry.screenshot).toString("base64"), fetchFn, signal);
} catch (err: any) {
result = { requested, path: requested, success: false, error: signal.aborted ? DEADLINE_ERROR : `Screenshot analysis failed: ${err.message}`, retryable: true };
}
if (analysis !== null) result = await generateVariant(opts.apiKey, evolvePrompt(analysis, entry.brief), requested, opts.size, opts.quality, fetchFn, signal);
}
if (result!.success) {
saved.push(result!.path);
acct.saved.push(result!.path);
} else {
acct.failures.push({ file: requested, reason: result!.error ?? "unknown error" });
}
if (result!.recovered) acct.recovered.push(result!.recovered);
return result!;
};
const check = async (): Promise<NonNullable<BatchVariant["check"]>> => {
if (signal.aborted) return { status: "skipped", issues: `${DEADLINE_ERROR} before the check` };
try {
const result = await checkMockup(saved.at(-1)!, entry.brief, { apiKey: opts.apiKey, fetchFn, signal });
return { status: result.status, issues: result.issues };
} catch (err: any) {
return { status: "skipped", issues: signal.aborted ? `${DEADLINE_ERROR} during the check` : `Check failed to run: ${err.message}` };
}
};
await sleep(index * (opts.staggerMs ?? STAGGER_MS), signal);
if (signal.aborted) return finish("failed", `${DEADLINE_ERROR} before start`, true, null);
console.error(` Starting variant ${variant} (${operation})...`);
let result = await attempt();
if (result.empty && !signal.aborted) result = await attempt();
if (!result.success) return finish(result.rateLimited ? "rate_limited" : "failed", result.error ?? "unknown error", result.retryable ?? true, null);
let checked = await check();
if (checked.status === "fail" && !signal.aborted) {
const regenerated = await attempt();
if (regenerated.success) checked = await check();
}
return finish("done", null, false, checked);
};
try {
return await Promise.all(entries.map(runOne));
} finally {
clearTimeout(deadline);
}
}
async function variantsFromBriefsFile(options: VariantsOptions): Promise<ExitCode> {
if (typeof options.briefsFile !== "string") throw new Error("--briefs-file needs the path of a JSON file");
if (options.brief !== undefined || options.briefFile !== undefined || options.viewports !== undefined) {
throw new Error("--briefs-file cannot be combined with --brief, --brief-file or --viewports; put each variant's brief in the file.");
}
const entries = readBriefsFile(options.briefsFile);
if (options.count !== undefined) console.error(`warning: --count is ignored with --briefs-file; generating ${entries.length} variants, one per entry.`);
const apiKey = requireProvider();
console.error(`Generating ${entries.length} variants from ${options.briefsFile}...`);
const startTime = Date.now();
const acct = newAccounting(entries.length);
const results = await runBriefsBatch(entries, {
apiKey, outputDir: options.outputDir, size: options.size || "1536x1024", quality: options.quality || "high",
}, acct);
const done = results.filter(r => r.status === "done");
console.error(`\n${done.length}/${results.length} variants generated (${((Date.now() - startTime) / 1000).toFixed(1)}s)`);
return emitVariantsResult(options.outputDir, acct, results.filter(r => r.status !== "done").map(r => path.basename(r.path)), {
validated: done.filter(r => r.check?.status === "pass").length,
variants: results,
}, done.map(r => r.path));
}
/**
* Generate N variants with staggered parallel execution.
*/
export async function variants(options: VariantsOptions): Promise<ExitCode> {
if (options.briefsFile !== undefined) return variantsFromBriefsFile(options);
let apiKey: DesignAuth;
let baseBrief: string;
try {
apiKey = requireProvider();
baseBrief = options.briefFile
? parseBrief(options.briefFile, true)
: parseBrief(options.brief!, false);
} catch (err: any) {
const reason = err?.message || String(err);
console.error(reason);
const acct = newAccounting(0);
acct.failures.push({ file: options.outputDir, reason });
return emitVariantsResult(options.outputDir, acct, []);
}
const quality = options.quality || "high";
// If viewports specified, generate responsive variants instead of style variants
if (options.viewports) {
return generateResponsiveVariants(apiKey, baseBrief, options.outputDir, options.viewports, quality);
}
// #2032: normalize at the consumption site so every caller (CLI or
// programmatic) gets the loud-on-nonsense contract; the ceiling derives
// from STYLE_VARIATIONS so it self-adjusts when styles are added.
const count = normalizeIntFlag(options.count, {
name: "count",
def: 3,
min: 1,
max: STYLE_VARIATIONS.length,
});
const size = options.size || "1536x1024";
console.error(`Generating ${count} variants...`);
const startTime = Date.now();
const jobs: VariantJob[] = [];
for (let i = 0; i < count; i++) {
const variation = STYLE_VARIATIONS[i] || "";
const letter = String.fromCharCode(65 + i);
jobs.push({
outputPath: path.join(options.outputDir, `variant-${letter}.png`),
prompt: variation ? `${baseBrief}\n\nStyle direction: ${variation}` : baseBrief,
size,
label: `variant ${letter}`,
});
}
const { acct, errors } = await runVariantJobs(apiKey, quality, jobs);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.error(`\n${acct.saved.length}/${count} variants generated (${elapsed}s)`);
return emitVariantsResult(options.outputDir, acct, errors);
}
const VIEWPORT_CONFIGS: Record<string, { size: string; suffix: string; desc: string }> = {
desktop: { size: "1536x1024", suffix: "desktop", desc: "Desktop (1536x1024)" },
tablet: { size: "1024x1024", suffix: "tablet", desc: "Tablet (1024x1024)" },
mobile: { size: "1024x1536", suffix: "mobile", desc: "Mobile (1024x1536, portrait)" },
};
async function generateResponsiveVariants(
apiKey: DesignAuth,
baseBrief: string,
outputDir: string,
viewports: string,
quality: string,
): Promise<ExitCode> {
const viewportList = viewports.split(",").map(v => v.trim().toLowerCase());
const configs = viewportList.map(v => VIEWPORT_CONFIGS[v]).filter(Boolean);
if (configs.length === 0) {
const reason = `No valid viewports. Use: desktop, tablet, mobile`;
console.error(reason);
const acct = newAccounting(viewportList.length);
acct.failures.push({ file: outputDir, reason });
return emitVariantsResult(outputDir, acct, [], { viewports: viewportList });
}
console.error(`Generating responsive variants: ${configs.map(c => c.desc).join(", ")}...`);
const startTime = Date.now();
const jobs: VariantJob[] = configs.map(config => ({
outputPath: path.join(outputDir, `responsive-${config.suffix}.png`),
prompt: `${baseBrief}\n\nViewport: ${config.desc}. Adapt the layout for this screen size. ${
config.suffix === "mobile" ? "Use a single-column layout, larger touch targets, and mobile navigation patterns." :
config.suffix === "tablet" ? "Use a responsive layout that works for medium screens." :
""
}`,
size: config.size,
label: config.desc,
}));
const { acct, errors } = await runVariantJobs(apiKey, quality, jobs);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.error(`\n${acct.saved.length}/${configs.length} responsive variants generated (${elapsed}s)`);
return emitVariantsResult(outputDir, acct, errors, { viewports: viewportList });
}