commit 5ae8622f2dfd7f79317e49083acbeefc113e4b4a Author: yunyaozhou Date: Fri Jul 17 00:16:00 2026 +0800 feat: add asynchronous Shujk image generation skill diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..6ccf88e --- /dev/null +++ b/SKILL.md @@ -0,0 +1,70 @@ +--- +name: shujkimagegen-async-api +description: Generate images through the server4 asynchronous Sub2API endpoint, poll the task, and download the resulting image. +--- + +# Shujk Async ImageGen API + +Use this helper when image generation should run asynchronously on server4. + +## Boundary + +- Do not print, copy, commit, or summarize API keys. +- Read only `OPENAI_API_KEY` from `~/.codex/auth.json`; do not read plaintext `.env` files or unrelated secrets. +- Keep the durable source in the Gitea repo `shujakuin/skill-shujkimagegen-api`; treat `ai-infra/skills/sources/shujkimagegen-api/` and HOME runtime copies as managed mirrors. +- Do not place this skill under the infra repo's `.agents/skills/` or manage it through cc-switch WebDAV. +- Treat this as a thin wrapper around the system `imagegen` CLI; do not copy or patch the system script. +- Current HOME runtime placements are `pc` and `a100` under `~/.agents/skills/shujkimagegen-api/`. Update only explicitly modeled placements. + +## Endpoint + +- Async endpoint: `https://sub2apius.shujk.top/v1/images/generations/async` + +The `/v1` suffix is required because `/models` is an application page while `/v1/models` is the authenticated OpenAI-compatible API. + +## Workflow + +Run the wrapper: + + ```bash + python3 ~/.agents/skills/shujkimagegen-async-api/scripts/shujk_imagegen.py \ + --prompt "..." --out output/imagegen/example.png + ``` + +The command submits a task, honors `Retry-After`, polls until completion, and downloads the object-storage URL. + +5. Save project-bound assets inside the active workspace and report the output path and final prompt. + +For source validation before placement, use the equivalent wrapper under the standalone checkout or infra mirror. + +## Installation Validation + +After placing the skill in `~/.agents/skills/shujkimagegen-api/`, validate the installed copy before reporting success: + +1. Confirm `SKILL.md`, `agents/openai.yaml`, and `scripts/shujk_imagegen.py` exist. +2. Check whether the selected Python environment can import `openai`. Do not add it to an unrelated project's dependencies just for validation. If it is absent and `uv` is available, use an ephemeral environment: + + ```bash + uv run --with openai python ~/.agents/skills/shujkimagegen-api/scripts/shujk_imagegen.py \ + --print-config-redacted + ``` + +3. Run a no-cost command-path test: + + ```bash + uv run --with openai python ~/.agents/skills/shujkimagegen-api/scripts/shujk_imagegen.py \ + generate --prompt "installation validation" --out /tmp/shujkimagegen-dry-run.png \ + --dry-run --no-augment + ``` + +4. When the user authorizes a real API test, generate one low-cost image with `gpt-image-2`, `quality=low`, and a simple prompt. Confirm the command exits successfully and inspect the resulting file as a real, non-empty image. +5. Remove test artifacts that are not intended deliverables. + +If validation requires a compatibility fix, edit the standalone Gitea checkout first, rerun validation, commit and push it, then resync the infra mirror and selected runtime placement. Never leave the only fix in `~/.agents/skills/`. + +## Failure Handling + +- If `OPENAI_API_KEY` is absent, report that auth is unavailable without revealing file contents. +- If the system imagegen CLI is missing, report its expected path. +- If the endpoint rejects a model or option, retry only with an explicitly compatible model or option. +- Do not silently fall back to `sub2apius.shujk.top` or another Cloudflare-proxied endpoint. diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..cfe0017 --- /dev/null +++ b/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Shujk Async ImageGen API" + short_description: "通过 server4 异步入口提交、轮询并下载图片" + default_prompt: "使用 $shujkimagegen-async-api 提交异步图片任务并下载结果。" diff --git a/scripts/shujk_imagegen.py b/scripts/shujk_imagegen.py new file mode 100644 index 0000000..70ca171 --- /dev/null +++ b/scripts/shujk_imagegen.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Submit and poll asynchronous Sub2API image tasks.""" +from __future__ import annotations +import argparse, base64, json, os, sys, time +from pathlib import Path +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError + +ORIGIN = "https://sub2apius.shujk.top" +AUTH = Path.home() / ".codex/auth.json" + +def die(msg: str) -> None: + print(f"Error: {msg}", file=sys.stderr); raise SystemExit(1) + +def key(path: Path) -> str: + try: data = json.loads(path.read_text()) + except Exception as exc: die(f"cannot read Codex auth: {exc}") + value = data.get("OPENAI_API_KEY") if isinstance(data, dict) else None + if not isinstance(value, str) or not value.strip(): die("OPENAI_API_KEY is missing") + return value.strip() + +def request(method: str, url: str, token: str, body: bytes | None = None) -> tuple[int, dict, dict]: + headers = {"Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/131 Safari/537.36"} + if body is not None: headers["Content-Type"] = "application/json" + req = Request(url, data=body, headers=headers, method=method) + try: + with urlopen(req, timeout=60) as resp: + return resp.status, dict(resp.headers), json.loads(resp.read()) + except HTTPError as exc: + raw = exc.read().decode(errors="replace") + try: payload = json.loads(raw) + except json.JSONDecodeError: payload = {"error": raw} + return exc.code, dict(exc.headers), payload + except URLError as exc: die(f"request failed: {exc}") + +def main() -> int: + p = argparse.ArgumentParser(description="Asynchronous Shujk image generation") + p.add_argument("--auth-file", type=Path, default=AUTH) + p.add_argument("--model", default="gpt-image-2") + p.add_argument("--prompt", required=True) + p.add_argument("--out", type=Path, required=True) + p.add_argument("--size", default="1024x1024") + p.add_argument("--quality", default="low") + p.add_argument("--poll-seconds", type=float, default=3) + p.add_argument("--timeout-seconds", type=float, default=1800) + p.add_argument("--dry-run", action="store_true") + args = p.parse_args() + payload = {"model": args.model, "prompt": args.prompt, "size": args.size, "quality": args.quality} + if args.dry_run: + print(json.dumps({"url": ORIGIN + "/v1/images/generations/async", "payload": payload}, indent=2)); return 0 + token = key(args.auth_file) + code, headers, task = request("POST", ORIGIN + "/v1/images/generations/async", token, json.dumps(payload).encode()) + if code != 202: die(f"async submit failed ({code}): {task}") + task_url = task.get("poll_url") or f"{ORIGIN}/v1/images/tasks/{task.get('task_id')}" + if task_url.startswith("/"): task_url = ORIGIN + task_url + deadline = time.monotonic() + args.timeout_seconds + while time.monotonic() < deadline: + try: + code, headers, state = request("GET", task_url, token) + except TimeoutError: + time.sleep(args.poll_seconds) + continue + if code >= 400: die(f"async poll failed ({code}): {state}") + status = state.get("status") + if status == "completed": + url = state.get("image_url") + if not url: die("completed task has no image_url") + data = None + last_error = None + for attempt in range(6): + try: + with urlopen(Request(url, headers={"Accept": "image/*", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/131 Safari/537.36"}), timeout=120) as resp: + data = resp.read() + break + except HTTPError as exc: + last_error = exc + if exc.code != 403 or attempt == 5: break + time.sleep(2 + attempt) + except Exception as exc: + last_error = exc + break + if data is None: die(f"image download failed: {last_error}") + args.out.parent.mkdir(parents=True, exist_ok=True); args.out.write_bytes(data) + print(json.dumps({"task_id": task.get("task_id"), "status": status, "out": str(args.out), "bytes": len(data)})); return 0 + if status == "failed": die(f"image task failed: {state.get('error')}") + delay = float(headers.get("Retry-After", args.poll_seconds)); time.sleep(max(1.0, delay)) + die("timed out waiting for image task") + +if __name__ == "__main__": raise SystemExit(main())