From f85d84c2846d824d617f8e3f355d93aae2ba0b8e Mon Sep 17 00:00:00 2001 From: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:01:42 +0800 Subject: [PATCH] feat: add Atlas Cloud image generation skill Signed-off-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> --- skills/atlas-cloud-image-generation/README.md | 56 +++++ skills/atlas-cloud-image-generation/SKILL.md | 76 ++++++ .../scripts/generate-image.mjs | 224 ++++++++++++++++++ .../tests/generate-image.test.mjs | 115 +++++++++ 4 files changed, 471 insertions(+) create mode 100644 skills/atlas-cloud-image-generation/README.md create mode 100644 skills/atlas-cloud-image-generation/SKILL.md create mode 100644 skills/atlas-cloud-image-generation/scripts/generate-image.mjs create mode 100644 skills/atlas-cloud-image-generation/tests/generate-image.test.mjs diff --git a/skills/atlas-cloud-image-generation/README.md b/skills/atlas-cloud-image-generation/README.md new file mode 100644 index 0000000..a351d5b --- /dev/null +++ b/skills/atlas-cloud-image-generation/README.md @@ -0,0 +1,56 @@ +# Atlas Cloud Image Generation + +A dependency-free agent skill for generating images with the Atlas Cloud +asynchronous media API. The included Node.js CLI submits a Seedream 5 Lite +text-to-image task, polls it to completion, and prints the output URLs as JSON. + +## Requirements + +- Node.js 18 or newer +- An Atlas Cloud API key in `ATLASCLOUD_API_KEY` + +```bash +export ATLASCLOUD_API_KEY="your-api-key-here" +``` + +## Usage + +```bash +node scripts/generate-image.mjs \ + --prompt "A minimal red cube on a white studio background" \ + --size "2048*2048" \ + --output-format jpeg +``` + +Successful output has this shape: + +```json +{ + "id": "prediction-id", + "status": "completed", + "model": "bytedance/seedream-v5.0-lite", + "outputs": ["https://example.invalid/generated-image.jpeg"] +} +``` + +## Options + +| Option | Default | Description | +| --- | --- | --- | +| `--prompt` | required | Text description of the image | +| `--size` | `2048*2048` | Image size accepted by the current model schema | +| `--output-format` | `jpeg` | `jpeg` or `png` | +| `--poll-interval-ms` | `3000` | Delay between status requests | +| `--max-attempts` | `60` | Maximum number of status requests | + +## Validation + +Run the protocol tests without an API key or network access: + +```bash +node --test tests/generate-image.test.mjs +``` + +Before changing the model or parameters, fetch the current model catalogue and +follow the selected model's live `schema` URL. Do not infer model IDs, request +fields, or allowed values. diff --git a/skills/atlas-cloud-image-generation/SKILL.md b/skills/atlas-cloud-image-generation/SKILL.md new file mode 100644 index 0000000..b6323e7 --- /dev/null +++ b/skills/atlas-cloud-image-generation/SKILL.md @@ -0,0 +1,76 @@ +--- +name: atlas-cloud-image-generation +description: > + Generate an image with the Atlas Cloud asynchronous media API. Use when: a user asks for Atlas Cloud image generation or a Seedream image. NOT for: video generation, image editing, or models whose live schema has not been checked. +--- + +# Atlas Cloud Image Generation + +Generate a text-to-image result with Atlas Cloud and poll the asynchronous task +until it completes. + +## Use when + +- The user explicitly asks to generate an image with Atlas Cloud. +- The user wants a Seedream 5 Lite text-to-image result. +- An agent needs a dependency-free CLI that returns generated image URLs. + +NOT for video generation, image editing, or switching to another model without +first checking that model's live Atlas Cloud schema. + +## Setup + +Set the API key in the environment. Never pass it as a command-line argument or +write it to a project file. + +```bash +export ATLASCLOUD_API_KEY="your-api-key-here" +``` + +## Generate + +```bash +node {baseDir}/scripts/generate-image.mjs \ + --prompt "A red paper lantern floating above a quiet lake" \ + --size "2048*2048" \ + --output-format png +``` + +The command writes JSON to stdout. Return the first URL in `outputs` to the +user, or preserve the full array when multiple outputs are present. + +## Workflow + +1. Confirm `ATLASCLOUD_API_KEY` is set. +2. Improve the user's prompt without changing the requested subject. +3. Run the CLI and wait for a completed task. +4. Return the generated URL from `outputs`. +5. If the task fails, report the CLI error instead of retrying with guessed + parameters. + +## Examples + +**User:** Generate a square product photo of a ceramic mug with Atlas Cloud. + +```bash +node {baseDir}/scripts/generate-image.mjs \ + --prompt "Studio product photo of a white ceramic mug, soft side light, clean gray background" \ + --size "2048*2048" +``` + +**User:** Make a wide cinematic landscape with Seedream. + +```bash +node {baseDir}/scripts/generate-image.mjs \ + --prompt "Wide cinematic landscape, mountain observatory at sunrise, detailed clouds" \ + --size "2848*1600" \ + --output-format jpeg +``` + +## Model safety + +The bundled CLI targets `bytedance/seedream-v5.0-lite`, using parameters and +endpoints verified from its live Atlas Cloud schema. Model IDs and schemas can +change. Fetch `https://api.atlascloud.ai/api/v1/models`, require +`display_console: true`, and inspect the model's `schema` URL before changing +the model or request fields. diff --git a/skills/atlas-cloud-image-generation/scripts/generate-image.mjs b/skills/atlas-cloud-image-generation/scripts/generate-image.mjs new file mode 100644 index 0000000..ea11a5c --- /dev/null +++ b/skills/atlas-cloud-image-generation/scripts/generate-image.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node + +import { pathToFileURL } from "node:url"; + +export const DEFAULT_MODEL = "bytedance/seedream-v5.0-lite"; +export const DEFAULT_BASE_URL = "https://api.atlascloud.ai"; +export const DEFAULT_SIZE = "2048*2048"; +export const DEFAULT_OUTPUT_FORMAT = "jpeg"; + +const COMPLETED_STATUSES = new Set(["completed", "succeeded"]); +const FAILED_STATUSES = new Set(["failed", "canceled", "cancelled"]); + +function readValue(argv, index, option) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${option} requires a value`); + } + return value; +} + +function readPositiveInteger(value, option) { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${option} must be a positive integer`); + } + return parsed; +} + +export function parseArgs(argv) { + const options = { + prompt: "", + size: DEFAULT_SIZE, + outputFormat: DEFAULT_OUTPUT_FORMAT, + pollIntervalMs: 3000, + maxAttempts: 60, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const option = argv[index]; + switch (option) { + case "--prompt": + options.prompt = readValue(argv, index, option); + index += 1; + break; + case "--size": + options.size = readValue(argv, index, option); + index += 1; + break; + case "--output-format": + options.outputFormat = readValue(argv, index, option); + index += 1; + break; + case "--poll-interval-ms": + options.pollIntervalMs = readPositiveInteger( + readValue(argv, index, option), + option, + ); + index += 1; + break; + case "--max-attempts": + options.maxAttempts = readPositiveInteger( + readValue(argv, index, option), + option, + ); + index += 1; + break; + case "--help": + case "-h": + options.help = true; + break; + default: + throw new Error(`Unknown option: ${option}`); + } + } + + if (!options.help && !options.prompt.trim()) { + throw new Error("--prompt is required"); + } + if (!new Set(["jpeg", "png"]).has(options.outputFormat)) { + throw new Error("--output-format must be jpeg or png"); + } + + return options; +} + +function unwrapPayload(payload) { + return payload?.data ?? payload; +} + +async function requestJson(url, init, fetchImpl) { + const response = await fetchImpl(url, init); + const body = await response.text(); + let payload; + + try { + payload = body ? JSON.parse(body) : {}; + } catch { + throw new Error(`Atlas Cloud returned invalid JSON (HTTP ${response.status})`); + } + + const apiCode = payload?.code; + if (!response.ok || (apiCode !== undefined && apiCode !== 200)) { + const message = + payload?.message ?? payload?.error?.message ?? `HTTP ${response.status}`; + throw new Error(`Atlas Cloud request failed: ${message}`); + } + + return payload; +} + +export async function generateImage(options, dependencies = {}) { + const { + apiKey, + baseUrl = DEFAULT_BASE_URL, + prompt, + size = DEFAULT_SIZE, + outputFormat = DEFAULT_OUTPUT_FORMAT, + pollIntervalMs = 3000, + maxAttempts = 60, + } = options; + const fetchImpl = dependencies.fetchImpl ?? globalThis.fetch; + const sleepImpl = + dependencies.sleepImpl ?? + ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + + if (!apiKey) { + throw new Error("ATLASCLOUD_API_KEY is required"); + } + if (typeof fetchImpl !== "function") { + throw new Error("A Fetch API implementation is required"); + } + + const origin = baseUrl.replace(/\/+$/, ""); + const headers = { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }; + const submission = await requestJson( + `${origin}/api/v1/model/generateVideo`, + { + method: "POST", + headers, + body: JSON.stringify({ + model: DEFAULT_MODEL, + prompt, + size, + output_format: outputFormat, + }), + }, + fetchImpl, + ); + const task = unwrapPayload(submission); + const predictionId = task?.id ?? task?.request_id; + + if (!predictionId) { + throw new Error("Atlas Cloud response did not include a prediction ID"); + } + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const prediction = await requestJson( + `${origin}/api/v1/model/result/${encodeURIComponent(predictionId)}`, + { method: "GET", headers }, + fetchImpl, + ); + const result = unwrapPayload(prediction); + const status = String(result?.status ?? "unknown").toLowerCase(); + + if (COMPLETED_STATUSES.has(status)) { + const outputs = Array.isArray(result?.outputs) ? result.outputs : []; + if (outputs.length === 0) { + throw new Error("Atlas Cloud generation completed without an output URL"); + } + return { + id: predictionId, + status, + model: result?.model ?? DEFAULT_MODEL, + outputs, + }; + } + if (FAILED_STATUSES.has(status)) { + throw new Error( + `Atlas Cloud generation failed: ${result?.error ?? result?.message ?? status}`, + ); + } + if (attempt < maxAttempts) { + await sleepImpl(pollIntervalMs); + } + } + + throw new Error( + `Atlas Cloud generation did not finish after ${maxAttempts} status requests`, + ); +} + +function printHelp() { + process.stdout.write(`Usage: node generate-image.mjs --prompt [options]\n\n`); + process.stdout.write(`Options:\n`); + process.stdout.write(` --size Default: ${DEFAULT_SIZE}\n`); + process.stdout.write(` --output-format Default: ${DEFAULT_OUTPUT_FORMAT}\n`); + process.stdout.write(` --poll-interval-ms Default: 3000\n`); + process.stdout.write(` --max-attempts Default: 60\n`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const result = await generateImage({ + ...options, + apiKey: process.env.ATLASCLOUD_API_KEY, + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/skills/atlas-cloud-image-generation/tests/generate-image.test.mjs b/skills/atlas-cloud-image-generation/tests/generate-image.test.mjs new file mode 100644 index 0000000..7c42b8f --- /dev/null +++ b/skills/atlas-cloud-image-generation/tests/generate-image.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_MODEL, + generateImage, + parseArgs, +} from "../scripts/generate-image.mjs"; + +function jsonResponse(payload, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(payload), + }; +} + +test("parseArgs applies schema-backed defaults", () => { + assert.deepEqual(parseArgs(["--prompt", "A red cube"]), { + prompt: "A red cube", + size: "2048*2048", + outputFormat: "jpeg", + pollIntervalMs: 3000, + maxAttempts: 60, + help: false, + }); +}); + +test("generateImage submits and polls an Atlas Cloud task", async () => { + const calls = []; + const responses = [ + jsonResponse({ code: 200, data: { id: "prediction-123" } }), + jsonResponse({ code: 200, data: { status: "processing" } }), + jsonResponse({ + code: 200, + data: { + status: "completed", + model: DEFAULT_MODEL, + outputs: ["https://example.invalid/image.jpeg"], + }, + }), + ]; + const fetchImpl = async (url, init) => { + calls.push({ url, init }); + return responses.shift(); + }; + + const result = await generateImage( + { + apiKey: "test-key", + baseUrl: "https://atlas.example/", + prompt: "A red cube", + pollIntervalMs: 1, + maxAttempts: 2, + }, + { fetchImpl, sleepImpl: async () => {} }, + ); + + assert.deepEqual(result, { + id: "prediction-123", + status: "completed", + model: DEFAULT_MODEL, + outputs: ["https://example.invalid/image.jpeg"], + }); + assert.equal( + calls[0].url, + "https://atlas.example/api/v1/model/generateVideo", + ); + assert.equal(calls[0].init.headers.Authorization, "Bearer test-key"); + assert.deepEqual(JSON.parse(calls[0].init.body), { + model: DEFAULT_MODEL, + prompt: "A red cube", + size: "2048*2048", + output_format: "jpeg", + }); + assert.equal( + calls[1].url, + "https://atlas.example/api/v1/model/result/prediction-123", + ); + assert.equal(calls.length, 3); +}); + +test("generateImage surfaces API failures", async () => { + await assert.rejects( + generateImage( + { + apiKey: "test-key", + prompt: "A red cube", + }, + { + fetchImpl: async () => + jsonResponse({ error: { message: "invalid key" } }, 401), + }, + ), + /Atlas Cloud request failed: invalid key/, + ); +}); + +test("generateImage rejects a completed task without output URLs", async () => { + const responses = [ + jsonResponse({ code: 200, data: { id: "prediction-123" } }), + jsonResponse({ code: 200, data: { status: "completed", outputs: [] } }), + ]; + + await assert.rejects( + generateImage( + { + apiKey: "test-key", + prompt: "A red cube", + }, + { fetchImpl: async () => responses.shift() }, + ), + /completed without an output URL/, + ); +});