From ad7a7a646d5f55f3a7d9d1933973dff935c3ab46 Mon Sep 17 00:00:00 2001 From: Davidlaizz <1830579068@qq.com> Date: Sat, 6 Jun 2026 04:53:00 +0800 Subject: [PATCH] feat(baoyu-image-gen): add Agnes AI image generation provider --- .../references/providers/agnes.md | 55 +++++ skills/baoyu-image-gen/scripts/main.ts | 44 +++- .../scripts/providers/agnes.test.ts | 213 ++++++++++++++++++ .../scripts/providers/agnes.ts | 189 ++++++++++++++++ skills/baoyu-image-gen/scripts/types.ts | 7 +- 5 files changed, 499 insertions(+), 9 deletions(-) create mode 100644 skills/baoyu-image-gen/references/providers/agnes.md create mode 100644 skills/baoyu-image-gen/scripts/providers/agnes.test.ts create mode 100644 skills/baoyu-image-gen/scripts/providers/agnes.ts diff --git a/skills/baoyu-image-gen/references/providers/agnes.md b/skills/baoyu-image-gen/references/providers/agnes.md new file mode 100644 index 0000000..cf12942 --- /dev/null +++ b/skills/baoyu-image-gen/references/providers/agnes.md @@ -0,0 +1,55 @@ +# Sapiens AI Agnes Image + +Read when the user picks `--provider agnes` or sets `default_model.agnes`. Default model is `agnes-image-2.1-flash`. + +## Models + +**`agnes-image-2.1-flash`** (only model) + +- Text-to-image and image-to-image (with `--ref`) in a single `/images/generations` endpoint +- Supports reference images as public URLs or Data URI (base64) +- Optimized for high information density, complex layouts, and rich details +- Size rules: both dimensions divisible by 32 (720px exception), long edge ≤ 2048, total pixels ≤ ~4M +- Default size: `1024x1024`; custom `--size` supports arbitrary WxH within the above rules +- `--ar` supported: computed as 2048-based size (long edge ≤ 2048, short edge proportional, both snapped to 32px); `1:1` special-cased to `1024x1024` + +## Response Format + +- The sync API always returns a URL +- Default (`--response-format file`): downloads the image and saves as `.png` +- Pass `--response-format url`: writes the URL string to `.txt` instead + +## `--n` Behavior + +The Agnes API returns a single image per request regardless of the `n` parameter. Passing `--n > 1` triggers a local error from `validateArgs` before any API call is made. + +## Behavior Notes + +- API key required: `AGNES_API_KEY` +- Base URL: `https://apihub.agnes-ai.com/v1` (override with `AGNES_BASE_URL`) +- Model override: `AGNES_IMAGE_MODEL` env +- `response_format` is always embedded in `extra_body` (not at request top level) +- Reference images: local files converted to Data URI base64 inline; remote URLs passed through +- Rate limit defaults: concurrency=3, startIntervalMs=1100 (override via `BAOYU_IMAGE_GEN_AGNES_CONCURRENCY` / `BAOYU_IMAGE_GEN_AGNES_START_INTERVAL_MS`) +- Timeout: 120s per request + +## Size Resolution + +- `--size ` wins over `--ar` +- `--ar` maps to a concrete size using the algorithm: long edge ≤ 2048, short edge proportional, both dimensions snapped to 32px +- `--ar 1:1` is special-cased to `1024x1024` + +### Common `--ar` Results + +| Aspect Ratio | Result | +|--------------|--------| +| `1:1` | `1024x1024` | +| `16:9` | `2048x1152` | +| `4:3` | `2048x1536` | +| `3:2` | `2048x1376` | +| `21:9` | `2048x896` | +| Unlisted ratio | Computed on the fly (portrait mirror swaps width/height) | + +## Official References + +- [Agnes AIGC API Hub](https://apihub.agnes-ai.com) diff --git a/skills/baoyu-image-gen/scripts/main.ts b/skills/baoyu-image-gen/scripts/main.ts index d17e913..ad646f4 100644 --- a/skills/baoyu-image-gen/scripts/main.ts +++ b/skills/baoyu-image-gen/scripts/main.ts @@ -65,6 +65,7 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record = { seedream: { concurrency: 3, startIntervalMs: 1100 }, azure: { concurrency: 3, startIntervalMs: 1100 }, "codex-cli": { concurrency: 1, startIntervalMs: 2000 }, + agnes: { concurrency: 3, startIntervalMs: 1100 }, }; function printUsage(): void { @@ -79,13 +80,14 @@ Options: --image Output image path (required in single-image mode) --batchfile JSON batch file for multi-image generation --jobs Worker count for batch mode (default: auto, max from config, built-in default 10) - --provider google|openai|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|azure|codex-cli Force provider (auto-detect by default) + --provider google|openai|openrouter|dashscope|zai|minimax|replicate|jimeng|seedream|azure|codex-cli|agnes Force provider (auto-detect by default) -m, --model Model ID --ar Aspect ratio (e.g., 16:9, 1:1, 4:3) --size Size (e.g., 1024x1024) --quality normal|2k Quality preset (default: 2k) --imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality) --imageApiDialect OpenAI-compatible image dialect: openai-native|ratio-metadata + --response-format file|url Output mode: file (download image, default) or url (return URL text) --ref Reference images (Google, OpenAI, Azure, OpenRouter, Replicate supported families, MiniMax, Seedream 4.0/4.5/5.0, or DashScope wan2.7-image*) --n Number of images for the current task (default: 1; Replicate currently requires 1) --json JSON output @@ -180,6 +182,7 @@ export function parseArgs(argv: string[]): CliArgs { imageSize: null, imageSizeSource: null, imageApiDialect: null, + responseFormat: null, referenceImages: [], n: 1, batchFile: null, @@ -265,7 +268,8 @@ export function parseArgs(argv: string[]): CliArgs { v !== "jimeng" && v !== "seedream" && v !== "azure" && - v !== "codex-cli" + v !== "codex-cli" && + v !== "agnes" ) { throw new Error(`Invalid provider: ${v}`); } @@ -319,6 +323,13 @@ export function parseArgs(argv: string[]): CliArgs { continue; } + if (a === "--response-format") { + const v = argv[++i]; + if (v !== "file" && v !== "url") throw new Error(`Invalid response-format: ${v}`); + out.responseFormat = v; + continue; + } + if (a === "--ref" || a === "--reference") { const { items, next } = takeMany(i); if (items.length === 0) throw new Error(`Missing files for ${a}`); @@ -438,6 +449,7 @@ export function parseSimpleYaml(yaml: string): Partial { seedream: null, azure: null, "codex-cli": null, + agnes: null, }; currentKey = "default_model"; currentProvider = null; @@ -641,9 +653,10 @@ export function getConfiguredProviderRateLimits( seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream }, azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure }, "codex-cli": { ...DEFAULT_PROVIDER_RATE_LIMITS["codex-cli"] }, + agnes: { ...DEFAULT_PROVIDER_RATE_LIMITS.agnes }, }; - for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure", "codex-cli"] as Provider[]) { + for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure", "codex-cli", "agnes"] as Provider[]) { const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase().replace(/-/g, "_")}`; const extendLimit = extendConfig.batch?.provider_limits?.[provider]; configured[provider] = { @@ -696,6 +709,7 @@ function inferProviderFromModel(model: string | null): Provider | null { if (normalized.includes("seedream") || normalized.includes("seededit")) return "seedream"; if (normalized === "image-01" || normalized === "image-01-live") return "minimax"; if (normalized === "glm-image" || normalized === "cogview-4-250304") return "zai"; + if (normalized.includes("agnes-image")) return "agnes"; return null; } @@ -711,10 +725,11 @@ export function detectProvider(args: CliArgs): Provider { args.provider !== "seedream" && args.provider !== "minimax" && args.provider !== "dashscope" && - args.provider !== "codex-cli" + args.provider !== "codex-cli" && + args.provider !== "agnes" ) { throw new Error( - "Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, --provider dashscope with a wan2.7 image model, --provider seedream for supported Seedream models, --provider minimax for MiniMax subject-reference workflows, or --provider codex-cli (Codex image_gen with references)." + "Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, --provider dashscope with a wan2.7 image model, --provider seedream for supported Seedream models, --provider minimax for MiniMax subject-reference workflows, --provider codex-cli (Codex image_gen with references), or --provider agnes (Agnes Image)." ); } @@ -730,6 +745,7 @@ export function detectProvider(args: CliArgs): Provider { const hasReplicate = !!process.env.REPLICATE_API_TOKEN; const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY); const hasSeedream = !!process.env.ARK_API_KEY; + const hasAgnes = !!process.env.AGNES_API_KEY; const modelProvider = inferProviderFromModel(args.model); if (modelProvider === "seedream") { @@ -753,6 +769,13 @@ export function detectProvider(args: CliArgs): Provider { return "zai"; } + if (modelProvider === "agnes") { + if (!hasAgnes) { + throw new Error("Model looks like an Agnes image model, but AGNES_API_KEY is not set."); + } + return "agnes"; + } + if (args.referenceImages.length > 0) { if (hasGoogle) return "google"; if (hasOpenai) return "openai"; @@ -761,8 +784,9 @@ export function detectProvider(args: CliArgs): Provider { if (hasReplicate) return "replicate"; if (hasSeedream) return "seedream"; if (hasMinimax) return "minimax"; + if (hasAgnes) return "agnes"; throw new Error( - "Reference images require Google, OpenAI, Azure, OpenRouter, Replicate, supported Seedream models, or MiniMax. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, ARK_API_KEY, or MINIMAX_API_KEY, or remove --ref." + "Reference images require Google, OpenAI, Azure, OpenRouter, Replicate, supported Seedream models, MiniMax, or Agnes. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, ARK_API_KEY, MINIMAX_API_KEY, or AGNES_API_KEY, or remove --ref." ); } @@ -777,13 +801,14 @@ export function detectProvider(args: CliArgs): Provider { hasReplicate && "replicate", hasJimeng && "jimeng", hasSeedream && "seedream", + hasAgnes && "agnes", ].filter(Boolean) as Provider[]; if (available.length === 1) return available[0]!; if (available.length > 1) return available[0]!; throw new Error( - "No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, ZAI_API_KEY, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" + + "No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, ZAI_API_KEY, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, ARK_API_KEY, or AGNES_API_KEY.\n" + "Create ~/.baoyu-skills/.env or /.baoyu-skills/.env with your keys." ); } @@ -852,6 +877,7 @@ async function loadProviderModule(provider: Provider): Promise { if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule; if (provider === "azure") return (await import("./providers/azure")) as ProviderModule; if (provider === "codex-cli") return (await import("./providers/codex-cli")) as ProviderModule; + if (provider === "agnes") return (await import("./providers/agnes")) as ProviderModule; return (await import("./providers/openai")) as ProviderModule; } @@ -884,6 +910,7 @@ function getModelForProvider( if (provider === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream; if (provider === "azure" && extendConfig.default_model.azure) return extendConfig.default_model.azure; if (provider === "codex-cli" && extendConfig.default_model["codex-cli"]) return extendConfig.default_model["codex-cli"]; + if (provider === "agnes" && extendConfig.default_model.agnes) return extendConfig.default_model.agnes; } return providerModule.getDefaultModel(); } @@ -966,6 +993,7 @@ export function createTaskArgs(baseArgs: CliArgs, task: BatchTaskInput, batchDir imageSize: task.imageSize ?? baseArgs.imageSize ?? null, imageSizeSource: task.imageSize != null ? "task" : (baseArgs.imageSizeSource ?? null), imageApiDialect: task.imageApiDialect ?? baseArgs.imageApiDialect ?? null, + responseFormat: task.responseFormat ?? baseArgs.responseFormat ?? null, referenceImages: task.ref ? task.ref.map((filePath) => resolveBatchReferencePath(batchDir, filePath)) : [], n: task.n ?? baseArgs.n, batchFile: null, @@ -1118,7 +1146,7 @@ async function runBatchTasks( const acquireProvider = createProviderGate(providerRateLimits); const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers); console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`); - for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure", "codex-cli"] as Provider[]) { + for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "zai", "minimax", "jimeng", "seedream", "azure", "codex-cli", "agnes"] as Provider[]) { const limit = providerRateLimits[provider]; console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`); } diff --git a/skills/baoyu-image-gen/scripts/providers/agnes.test.ts b/skills/baoyu-image-gen/scripts/providers/agnes.test.ts new file mode 100644 index 0000000..287f7cd --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/agnes.test.ts @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test, { type TestContext } from "node:test"; + +import type { CliArgs } from "../types.ts"; +import { + buildRequestBody, + extractImageFromResponse, + parseAspectRatio, + resolveReferenceImages, + resolveSize, + snapDim, + validateArgs, +} from "./agnes.ts"; + +function useEnv( + t: TestContext, + values: Record, +): void { + const previous = new Map(); + for (const [key, value] of Object.entries(values)) { + previous.set(key, process.env[key]); + if (value == null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + t.after(() => { + for (const [key, value] of previous.entries()) { + if (value == null) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); +} + +function makeArgs(overrides: Partial = {}): CliArgs { + return { + prompt: null, + promptFiles: [], + imagePath: null, + provider: null, + model: null, + aspectRatio: null, + size: null, + quality: null, + imageSize: null, + imageApiDialect: null, + referenceImages: [], + n: 1, + batchFile: null, + jobs: null, + json: false, + help: false, + responseFormat: null, + ...overrides, + }; +} + +test("snapDim rounds to the nearest multiple of 32", () => { + assert.equal(snapDim(767), 768); + assert.equal(snapDim(1023), 1024); + assert.equal(snapDim(1024), 1024); + assert.equal(snapDim(32), 32); + assert.equal(snapDim(0), 32); + assert.equal(snapDim(16), 32); + assert.equal(snapDim(48), 64); +}); + +test("parseAspectRatio parses valid ratios and rejects invalid inputs", () => { + assert.deepEqual(parseAspectRatio("3:4"), { width: 3, height: 4 }); + assert.deepEqual(parseAspectRatio("16:9"), { width: 16, height: 9 }); + assert.deepEqual(parseAspectRatio("1:1"), { width: 1, height: 1 }); + assert.deepEqual(parseAspectRatio("1.5:1"), { width: 1.5, height: 1 }); + + assert.equal(parseAspectRatio(""), null); + assert.equal(parseAspectRatio("invalid"), null); + assert.equal(parseAspectRatio("3x4"), null); + assert.equal(parseAspectRatio("0:1"), null); + assert.equal(parseAspectRatio("1:0"), null); +}); + +test("resolveSize returns explicit --size directly", () => { + assert.equal(resolveSize({ size: "1024x1024" }), "1024x1024"); + assert.equal(resolveSize({ size: "768x1024", aspectRatio: "16:9" }), "768x1024"); +}); + +test("resolveSize returns default 1024x1024 when no size or ratio given", () => { + assert.equal(resolveSize({}), "1024x1024"); + assert.equal(resolveSize({ size: null, aspectRatio: null }), "1024x1024"); +}); + +test("resolveSize computes 32-aligned size within 2048 max edge", () => { + assert.equal(resolveSize({ aspectRatio: "1:1" }), "1024x1024"); + assert.equal(resolveSize({ aspectRatio: "16:9" }), "2048x1152"); + assert.equal(resolveSize({ aspectRatio: "4:3" }), "2048x1536"); + assert.equal(resolveSize({ aspectRatio: "3:4" }), "1536x2048"); + assert.equal(resolveSize({ aspectRatio: "9:16" }), "1152x2048"); +}); + +test("resolveSize aligns to 32 and respects max edge", () => { + assert.equal(resolveSize({ aspectRatio: "3:1" }), "2048x672"); + assert.equal(resolveSize({ aspectRatio: "1:3" }), "672x2048"); +}); + +test("validateArgs rejects --n > 1", () => { + assert.throws( + () => validateArgs("agnes-image-2.1-flash", makeArgs({ n: 2 })), + /returns a single image per request/, + ); + assert.doesNotThrow(() => + validateArgs("agnes-image-2.1-flash", makeArgs({ n: 1 })), + ); +}); + +test("buildRequestBody maps prompt, model, size, and reference images", () => { + const body = buildRequestBody("a cat", "agnes-image-2.1-flash", { + size: "1024x1024", + aspectRatio: null, + referenceImages: [], + }); + assert.equal(body.model, "agnes-image-2.1-flash"); + assert.equal(body.prompt, "a cat"); + assert.equal(body.size, "1024x1024"); + assert.deepEqual(body.extra_body, { response_format: "url" }); + + const bodyWithRef = buildRequestBody("a cat", "agnes-image-2.1-flash", { + size: null, + aspectRatio: "3:4", + referenceImages: ["https://example.com/ref.jpg"], + }); + assert.equal(bodyWithRef.size, "1536x2048"); + assert.deepEqual(bodyWithRef.image, ["https://example.com/ref.jpg"]); +}); + +test("extractImageFromResponse decodes b64_json payloads", async () => { + const fromBase64 = await extractImageFromResponse({ + data: [{ b64_json: Buffer.from("hello").toString("base64") }], + }); + assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello"); +}); + +test("extractImageFromResponse downloads URL payloads", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + globalThis.fetch = async () => + new Response(Uint8Array.from([1, 2, 3]), { + status: 200, + headers: { "Content-Type": "image/png" }, + }); + + const fromUrl = await extractImageFromResponse({ + data: [{ url: "https://example.com/output.png" }], + }); + assert.deepEqual([...fromUrl], [1, 2, 3]); +}); + +test("extractImageFromResponse throws on empty data", async () => { + await assert.rejects( + () => extractImageFromResponse({ data: [] }), + /No image/, + ); + await assert.rejects( + () => extractImageFromResponse({ data: [{}] }), + /No image/, + ); +}); + +test("resolveReferenceImages converts local files to data URIs and passes URLs through", async (t) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agnes-ref-")); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + + const localPath = path.join(dir, "ref.png"); + const localBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + await fs.writeFile(localPath, localBytes); + + const jpegPath = path.join(dir, "photo.jpeg"); + await fs.writeFile(jpegPath, Buffer.from([0xff, 0xd8])); + + const results = await resolveReferenceImages([ + localPath, + "https://example.com/remote.jpg", + jpegPath, + ]); + + assert.equal(results.length, 3); + assert.match(results[0]!, /^data:image\/png;base64,/); + assert.match(results[1]!, /^https:\/\/example.com\/remote.jpg$/); + assert.match(results[2]!, /^data:image\/jpeg;base64,/); +}); + +test("resolveReferenceImages detects gif and webp mime types", async (t) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agnes-mime-")); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + + const webpPath = path.join(dir, "ref.webp"); + const gifPath = path.join(dir, "ref.gif"); + await fs.writeFile(webpPath, Buffer.from([0x00])); + await fs.writeFile(gifPath, Buffer.from([0x00])); + + const results = await resolveReferenceImages([webpPath, gifPath]); + assert.match(results[0]!, /^data:image\/webp;base64,/); + assert.match(results[1]!, /^data:image\/gif;base64,/); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/agnes.ts b/skills/baoyu-image-gen/scripts/providers/agnes.ts new file mode 100644 index 0000000..0cf6a12 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/agnes.ts @@ -0,0 +1,189 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import type { CliArgs } from "../types"; + +const DEFAULT_MODEL = "agnes-image-2.1-flash"; +const DEFAULT_BASE_URL = "https://apihub.agnes-ai.com/v1"; +const DEFAULT_SIZE = "1024x1024"; + +type AgnesResponse = { + created?: number; + data: Array<{ url?: string; b64_json?: string }>; +}; + +export function getDefaultModel(): string { + return process.env.AGNES_IMAGE_MODEL || DEFAULT_MODEL; +} + +function getApiKey(): string { + const key = process.env.AGNES_API_KEY; + if (!key) { + throw new Error("AGNES_API_KEY is required. Get one from https://apihub.agnes-ai.com."); + } + return key; +} + +function getBaseUrl(): string { + return (process.env.AGNES_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, ""); +} + +export function parseAspectRatio(ar: string): { width: number; height: number } | null { + const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/); + if (!match) return null; + const w = parseFloat(match[1]!); + const h = parseFloat(match[2]!); + if (w <= 0 || h <= 0) return null; + return { width: w, height: h }; +} + +function gcd(a: number, b: number): number { + let x = Math.abs(Math.round(a)); + let y = Math.abs(Math.round(b)); + while (y !== 0) { + const next = x % y; + x = y; + y = next; + } + return x || 1; +} + +export function snapDim(n: number): number { + return Math.max(32, Math.round(n / 32) * 32); +} + +export function resolveSize(args: Pick): string { + if (args.size) return args.size; + + if (args.aspectRatio) { + const parsed = parseAspectRatio(args.aspectRatio); + if (parsed) { + const g = gcd(parsed.width, parsed.height); + const rw = Math.round(parsed.width / g); + const rh = Math.round(parsed.height / g); + if (rw === 1 && rh === 1) return "1024x1024"; + const maxEdge = 2048; + const scale = Math.max(1, Math.floor(maxEdge / Math.max(rw, rh))); + const width = rw * scale; + const height = rh * scale; + return `${snapDim(width)}x${snapDim(height)}`; + } + } + + return DEFAULT_SIZE; +} + +function isRemoteUrl(refPath: string): boolean { + return /^https?:\/\//i.test(refPath); +} + +export async function resolveReferenceImages( + referenceImages: string[] +): Promise { + const result: string[] = []; + for (const refPath of referenceImages) { + if (isRemoteUrl(refPath)) { + result.push(refPath); + continue; + } + const bytes = await readFile(refPath); + const ext = path.extname(refPath).toLowerCase(); + let mime = "image/png"; + if (ext === ".jpg" || ext === ".jpeg") mime = "image/jpeg"; + else if (ext === ".webp") mime = "image/webp"; + else if (ext === ".gif") mime = "image/gif"; + const b64 = Buffer.from(bytes).toString("base64"); + result.push(`data:${mime};base64,${b64}`); + } + return result; +} + +export function validateArgs(_model: string, args: CliArgs): void { + if (args.n > 1) { + throw new Error("Agnes image generation currently returns a single image per request. Set --n 1 or omit --n."); + } +} + +export function getDefaultOutputExtension(_model: string, args: CliArgs): string { + return args.responseFormat === "url" ? ".txt" : ".png"; +} + +export function buildRequestBody( + prompt: string, + model: string, + args: Pick +): Record { + const body: Record = { + model, + prompt, + size: resolveSize(args), + }; + + if (args.referenceImages.length > 0) { + body.image = args.referenceImages; + } + + body.extra_body = { response_format: "url" }; + + return body; +} + +export async function extractImageFromResponse(result: AgnesResponse): Promise { + const img = result.data[0]; + + if (img?.b64_json) { + return Uint8Array.from(Buffer.from(img.b64_json, "base64")); + } + + if (img?.url) { + const imgRes = await fetch(img.url); + if (!imgRes.ok) throw new Error(`Failed to download image from Agnes: ${imgRes.status}`); + return new Uint8Array(await imgRes.arrayBuffer()); + } + + throw new Error("No image in Agnes response"); +} + +export async function generateImage( + prompt: string, + model: string, + args: CliArgs +): Promise { + const apiKey = getApiKey(); + const baseUrl = getBaseUrl(); + + const referenceImages = await resolveReferenceImages(args.referenceImages); + + const body = buildRequestBody(prompt, model, { ...args, referenceImages }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 120_000); + + try { + const res = await fetch(`${baseUrl}/images/generations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Agnes API error (${res.status}): ${err}`); + } + + const result = (await res.json()) as AgnesResponse; + + if (args.responseFormat === "url") { + const url = result.data[0]?.url; + if (!url) throw new Error("No URL in Agnes response"); + return new Uint8Array(Buffer.from(url, "utf-8")); + } + + return extractImageFromResponse(result); + } finally { + clearTimeout(timeout); + } +} diff --git a/skills/baoyu-image-gen/scripts/types.ts b/skills/baoyu-image-gen/scripts/types.ts index b3d1723..2a1cd4c 100644 --- a/skills/baoyu-image-gen/scripts/types.ts +++ b/skills/baoyu-image-gen/scripts/types.ts @@ -9,9 +9,11 @@ export type Provider = | "jimeng" | "seedream" | "azure" - | "codex-cli"; + | "codex-cli" + | "agnes"; export type Quality = "normal" | "2k"; export type OpenAIImageApiDialect = "openai-native" | "ratio-metadata"; +export type ResponseFormat = "file" | "url"; export type CliArgs = { prompt: string | null; @@ -26,6 +28,7 @@ export type CliArgs = { imageSize: string | null; imageSizeSource?: "cli" | "task" | "config" | null; imageApiDialect: OpenAIImageApiDialect | null; + responseFormat: ResponseFormat | null; referenceImages: string[]; n: number; batchFile: string | null; @@ -46,6 +49,7 @@ export type BatchTaskInput = { quality?: Quality | null; imageSize?: "1K" | "2K" | "4K" | null; imageApiDialect?: OpenAIImageApiDialect | null; + responseFormat?: ResponseFormat | null; ref?: string[]; n?: number; }; @@ -76,6 +80,7 @@ export type ExtendConfig = { seedream: string | null; azure: string | null; "codex-cli": string | null; + agnes: string | null; }; batch?: { max_workers?: number | null;