Add initial image CLI runtime

This commit is contained in:
2026-03-21 13:41:14 +08:00
commit eeacb22868
4 changed files with 210 additions and 0 deletions

91
src/ai.ts Normal file
View File

@@ -0,0 +1,91 @@
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { streamText } from "ai";
import type { Config } from "@/config";
type GeneratedImage = {
bytes: Uint8Array;
mediaType: string;
};
const MARKDOWN_IMAGE_REGEX = /!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/;
function createProvider(config: Config) {
return createOpenAICompatible({
name: "flow2api",
baseURL: config.baseURL,
apiKey: config.apiKey,
});
}
function writeSectionTitle(title: string, alreadyPrinted: boolean): void {
if (alreadyPrinted) {
return;
}
process.stdout.write(`\n${title}:\n\n`);
}
function extractImageUrl(text: string): string | null {
const match = text.match(MARKDOWN_IMAGE_REGEX);
return match?.[1] ?? null;
}
async function downloadImage(url: string): Promise<GeneratedImage> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to download generated image: ${response.status} ${response.statusText}`,
);
}
const mediaType =
response.headers.get("content-type")?.split(";")[0]?.trim() || "image/png";
return {
bytes: new Uint8Array(await response.arrayBuffer()),
mediaType,
};
}
export async function streamPrompt(
config: Config,
prompt: string,
): Promise<GeneratedImage> {
const provider = createProvider(config);
const result = streamText({
model: provider(config.model),
prompt,
});
let reasoningStarted = false;
let textStarted = false;
let textOutput = "";
for await (const chunk of result.fullStream) {
if (chunk.type === "reasoning-delta") {
writeSectionTitle("Thoughts", reasoningStarted);
reasoningStarted = true;
process.stdout.write(chunk.text);
continue;
}
if (chunk.type === "text-delta") {
writeSectionTitle("Response", textStarted);
textStarted = true;
textOutput += chunk.text;
process.stdout.write(chunk.text);
}
}
const imageUrl = extractImageUrl(textOutput);
if (!imageUrl) {
throw new Error("The streamed response did not include an image URL.");
}
process.stdout.write("\n\nDownloading image...\n");
return downloadImage(imageUrl);
}

51
src/config.ts Normal file
View File

@@ -0,0 +1,51 @@
export type Config = {
apiKey: string;
baseURL: string;
model: string;
};
const DEFAULT_MODEL = "gemini-3.0-pro-image-landscape";
function getRequiredEnv(primaryName: string, fallbackName?: string): string {
const primaryValue = Bun.env[primaryName]?.trim();
if (primaryValue) {
return primaryValue;
}
if (fallbackName) {
const fallbackValue = Bun.env[fallbackName]?.trim();
if (fallbackValue) {
return fallbackValue;
}
}
throw new Error(
`Missing required environment variable: ${primaryName}${fallbackName ? ` (or ${fallbackName})` : ""}`,
);
}
export function normalizeBaseUrl(baseURL: string): string {
const url = new URL(baseURL);
if (url.pathname === "" || url.pathname === "/") {
url.pathname = "/v1";
}
return url.toString().replace(/\/$/, "");
}
export function loadConfig(): Config {
return {
apiKey: getRequiredEnv("FLOW2API_API_KEY", "OPENAI_API_KEY"),
baseURL: normalizeBaseUrl(
getRequiredEnv("FLOW2API_BASE_URL", "OPENAI_BASE_URL"),
),
model:
Bun.env.FLOW2API_MODEL?.trim() ||
Bun.env.GEMINI_MODEL?.trim() ||
Bun.env.OPENAI_MODEL?.trim() ||
DEFAULT_MODEL,
};
}

37
src/image.ts Normal file
View File

@@ -0,0 +1,37 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
const IMAGES_DIR = "images";
function mediaTypeToExtension(mediaType: string): string {
switch (mediaType) {
case "image/png":
return "png";
case "image/jpeg":
return "jpg";
case "image/webp":
return "webp";
case "image/gif":
return "gif";
default:
return "bin";
}
}
function createTimestampFileName(mediaType: string): string {
const extension = mediaTypeToExtension(mediaType);
const timestamp = new Date().toISOString().replace(/[.:]/g, "-");
return `${timestamp}.${extension}`;
}
export async function saveImage(
bytes: Uint8Array,
mediaType: string,
): Promise<string> {
await mkdir(IMAGES_DIR, { recursive: true });
const filePath = join(IMAGES_DIR, createTimestampFileName(mediaType));
await writeFile(filePath, bytes);
return filePath;
}

31
src/index.ts Normal file
View File

@@ -0,0 +1,31 @@
import { streamPrompt } from "@/ai";
import { loadConfig } from "@/config";
import { saveImage } from "@/image";
const DEFAULT_PROMPT =
"A cute nano banano mascot in a clean sci-fi lab, bright colors, polished illustration";
function buildPrompt(): string {
const cliPrompt = Bun.argv.slice(2).join(" ").trim();
return cliPrompt || DEFAULT_PROMPT;
}
async function main(): Promise<void> {
const config = loadConfig();
const prompt = buildPrompt();
console.log(`Prompt: ${prompt}`);
const image = await streamPrompt(config, prompt);
const savedImagePath = await saveImage(image.bytes, image.mediaType);
console.log(`\nSaved image: ${savedImagePath}`);
console.log(`Image media type: ${image.mediaType}`);
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`\nRequest failed: ${message}`);
process.exitCode = 1;
});