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