Files
gstack/design/src/evolve.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

176 lines
6.7 KiB
TypeScript

/**
* Screenshot-to-Mockup Evolution.
* 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.
*/
import fs from "fs";
import { requireProvider, type ProviderKey } from "./auth";
import { receiptedFetch } from "./receipted-fetch";
import { modelRejectionHint } from "./models";
import {
asProvider,
geminiFailure,
geminiSwitchFix,
imageRequest,
isOpenAiVerificationError,
readImage,
visionErrorHint,
visionRequest,
type DesignAuth,
} from "./provider";
import { emitResult, exitCodeFor, newAccounting, persistImage, recordOutcome, type ExitCode } from "./persist";
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
}
/**
* Generate an evolved mockup from an existing screenshot + brief.
* Sends the screenshot as context to the image model with image generation,
* asking it to produce a new version incorporating the brief's changes.
*/
export async function evolve(options: EvolveOptions): Promise<ExitCode> {
const acct = newAccounting(1);
let outputPath: string | null = null;
try {
const provider = requireProvider();
const screenshotData = fs.readFileSync(options.screenshot).toString("base64");
console.error(`Evolving ${options.screenshot} 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(provider, screenshotData);
console.error(` Analyzed current design: ${analysis.slice(0, 100)}...`);
// Step 2: Generate evolved version using analysis + brief
const evolvedPrompt = evolvePrompt(analysis, options.brief);
const imageData = await requestEvolvedImage(provider, evolvedPrompt);
const outcome = persistImage(imageData, options.output);
outputPath = recordOutcome(acct, outcome);
if (outcome.ok) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.error(`Generated (${elapsed}s, ${(outcome.bytes / 1024).toFixed(0)}KB) → ${outcome.path}`);
}
} catch (err: any) {
const reason = err?.message || String(err);
console.error(reason);
acct.failures.push({ file: options.output, reason });
}
return emitResult({
outputPath,
sourceScreenshot: options.screenshot,
brief: options.brief,
attempts: acct.saved.map(p => ({ path: p })),
requested: acct.requested,
saved: acct.saved,
selected: outputPath,
failures: acct.failures,
recovered: acct.recovered,
}, exitCodeFor(outputPath !== null, acct.saved.length));
}
async function requestEvolvedImage(provider: ProviderKey, evolvedPrompt: string): Promise<string> {
const request = imageRequest(provider, { prompt: evolvedPrompt, size: "1536x1024", quality: "high" });
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 240_000);
try {
const response = await receiptedFetch("evolve-image-request", request.url, { ...request.init, signal: controller.signal }, globalThis.fetch, request.receipt);
if (!response.ok) {
const error = await response.text();
if (provider.provider === "gemini") throw new Error(geminiFailure(provider, response.status, error, "image").message);
if (isOpenAiVerificationError(response.status, error)) {
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.\n"
+ geminiSwitchFix(),
);
}
throw new Error(`API error (${response.status}): ${error.slice(0, 300)}${modelRejectionHint(response.status, error, "image")}`);
}
const image = readImage(provider, await response.json());
if (!image) {
throw new Error("No image data in response");
}
return image.imageData;
} finally {
clearTimeout(timeout);
}
}
/** The image prompt for an evolved mockup: the analyzed current design plus the requested changes. */
export function evolvePrompt(analysis: string, brief: string): string {
return [
"Generate a pixel-perfect UI mockup that is an improved version of an existing design.",
"",
"CURRENT DESIGN (what exists now):",
analysis,
"",
"REQUESTED CHANGES:",
brief,
"",
"Generate a new mockup that keeps the existing layout structure but applies the requested changes.",
"The result should look like a real production UI. All text must be readable.",
"1536x1024 pixels.",
].join("\n");
}
/**
* Analyze a screenshot to produce a detailed description for re-generation.
*/
export async function analyzeScreenshot(
auth: DesignAuth,
imageBase64: string,
fetchFn: typeof globalThis.fetch = globalThis.fetch,
batchSignal?: AbortSignal,
): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
const signal = batchSignal ? AbortSignal.any([controller.signal, batchSignal]) : controller.signal;
const provider = asProvider(auth);
try {
const request = visionRequest(provider, [{
role: "user",
content: [
{
type: "image_url",
image_url: { url: `data:image/png;base64,${imageBase64}` },
},
{
type: "text",
text: `Describe this UI in detail for re-creation. Include: overall layout structure, color scheme (hex values), typography (sizes, weights), specific text content visible, spacing between elements, alignment patterns, and any decorative elements. Be precise enough that someone could recreate this UI from your description alone. 200 words max.`,
},
],
}], 400);
const response = await receiptedFetch("evolve-screenshot-analysis-request", request.url, { ...request.init, signal }, fetchFn, request.receipt);
if (!response.ok) {
const error = await response.text();
console.error(` Screenshot analysis failed (${response.status}): ${error.slice(0, 200)}${visionErrorHint(provider, response.status, error)}`);
return "Unable to analyze screenshot";
}
const data = await response.json() as any;
return data.choices?.[0]?.message?.content?.trim() || "Unable to analyze screenshot";
} finally {
clearTimeout(timeout);
}
}