Add initial image CLI runtime
This commit is contained in:
91
src/ai.ts
Normal file
91
src/ai.ts
Normal 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
51
src/config.ts
Normal 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
37
src/image.ts
Normal 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
31
src/index.ts
Normal 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;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user