commit eeacb22868bb97a09b59bff6c9ba5d633f3093be Author: imbytecat Date: Sat Mar 21 13:41:14 2026 +0800 Add initial image CLI runtime diff --git a/src/ai.ts b/src/ai.ts new file mode 100644 index 0000000..e380453 --- /dev/null +++ b/src/ai.ts @@ -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 { + 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 { + 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); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..7cdedd6 --- /dev/null +++ b/src/config.ts @@ -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, + }; +} diff --git a/src/image.ts b/src/image.ts new file mode 100644 index 0000000..56a1a1a --- /dev/null +++ b/src/image.ts @@ -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 { + await mkdir(IMAGES_DIR, { recursive: true }); + + const filePath = join(IMAGES_DIR, createTimestampFileName(mediaType)); + await writeFile(filePath, bytes); + + return filePath; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ba6fb5d --- /dev/null +++ b/src/index.ts @@ -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 { + 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; +});