Jack 1 month ago
parent 0d63239270
commit fdbc22441b
  1. 4
      .env.example
  2. 103
      SKILL.md
  3. 554
      docs/agnes-ai-doc.md
  4. 103
      main.py
  5. 0
      utils/__init__.py
  6. 140
      utils/agnes_image.py
  7. 85
      utils/mm_api_t2i.py

@ -1,3 +1,7 @@
GROK_API_KEY=your_grok_api_key GROK_API_KEY=your_grok_api_key
GPT_API_KEY=your_gpt_api_key GPT_API_KEY=your_gpt_api_key
IMAGE_API_BASE_URL=https://api.slomerex.xyz/v1 IMAGE_API_BASE_URL=https://api.slomerex.xyz/v1
# Agnes Image 2.1 Flash
AGNES_API_KEY=your_agnes_api_key
AGNES_API_BASE_URL=https://apihub.agnes-ai.com/v1

@ -13,9 +13,10 @@ description: Generate images via AI text-to-image API (GPT Image / Grok). Use wh
| 能力 | 服务端文件 | HTTP 端点 | 模型 | | 能力 | 服务端文件 | HTTP 端点 | 模型 |
|------|------------|------------|------| |------|------------|------------|------|
| GPT / Grok 绘图 | `agent-image-t2i.py` | `POST /images/generations` | `gpt-image-2-1K` / `gpt-image-2-2K` / `gpt-image-2-4K` / `grok-imagine-image-lite` | | GPT / Grok 绘图 | `main.py` | `POST /images/generations` | `gpt-image-2-1K` / `gpt-image-2-2K` / `gpt-image-2-4K` / `grok-imagine-image-lite` |
| 健康检查 | `agent-image-t2i.py` | `GET /health` | — | | Agnes 绘图(文生图 / 图生图 / 多图合成) | `main.py` | `POST /agnes/images/generations` | `agnes-image-2.1-flash` |
| 图片访问 | `agent-image-t2i.py` | `GET /output/{profile_name}/{filename}` | — | | 健康检查 | `main.py` | `GET /health` | — |
| 图片访问 | `main.py` | `GET /output/{profile_name}/{filename}` | — |
## GPT 模型说明 ## GPT 模型说明
@ -68,6 +69,102 @@ curl -X POST http://localhost:8765/images/generations \
生成结果中的 `url` 为图片访问地址,例如 `http://localhost:8765/output/{profile_name}/{filename}` 生成结果中的 `url` 为图片访问地址,例如 `http://localhost:8765/output/{profile_name}/{filename}`
## Agnes Image 2.1 Flash
升级版图像生成模型,针对高信息密度图像和复杂构图做了优化。同时支持**文生图、图生图、多图合成**。
### 调用示例
文生图(2K, 16:9):
```bash
curl -X POST http://localhost:8765/agnes/images/generations \
-H "Content-Type: application/json" \
-d '{
"prompt": "a luminous floating city above a misty canyon at sunrise, cinematic realism",
"size": "2K",
"ratio": "16:9",
"profile_name": "profile_name"
}'
```
图生图(传入参考图 URL 或 Data URI Base64):
```bash
curl -X POST http://localhost:8765/agnes/images/generations \
-H "Content-Type: application/json" \
-d '{
"prompt": "turn the scene into a rainy cyberpunk night while preserving the original composition",
"size": "1K",
"ratio": "16:9",
"images": ["https://example.com/input.png"],
"profile_name": "profile_name"
}'
```
多图合成(`images` 传多张):
```bash
curl -X POST http://localhost:8765/agnes/images/generations \
-H "Content-Type: application/json" \
-d '{
"prompt": "combine the two characters into an intense fantasy battle scene, dynamic lighting",
"size": "1K",
"ratio": "16:9",
"images": [
"https://example.com/character-1.png",
"https://example.com/character-2.png"
],
"profile_name": "profile_name"
}'
```
响应示例:
```json
{
"model": "agnes-image-2.1-flash",
"size": "2624x1472",
"ratio": "16:9",
"images": [
{"filename": "agnes-image-2.1-flash-xxxx.png", "url": "/output/profile_name/agnes-image-2.1-flash-xxxx.png", "bytes": 1234567}
]
}
```
返回的 `size` 是根据 `size` 档位 + `ratio` 解析出的实际像素尺寸,便于拼接访问地址。
### 请求参数
请求体为 JSON,发送到 `POST /agnes/images/generations`
- `profile_name`:**必填**,调用方 agent 的标识,图片会保存到 `output/<profile_name>/`
- `prompt`:必填,文本提示词
- `size`:可选,尺寸档位 `1K` / `2K` / `3K` / `4K`,或精确尺寸如 `1024x1024`;默认 `1K`
- `ratio`:可选,宽高比,与 `size` 档位配合使用;支持 `1:1` / `3:4` / `4:3` / `16:9` / `9:16` / `2:3` / `3:2` / `21:9`;默认 `1:1`
- `images`:可选,图生图或多图合成的输入图像 URL 或 Data URI Base64 列表
### 输出尺寸参考
| Ratio | 1K | 2K | 3K | 4K |
|--------|-------------|-------------|-------------|-------------|
| `1:1` | `1024x1024` | `2048x2048` | `3072x3072` | `4096x4096` |
| `3:4` | `864x1152` | `1728x2304` | `2592x3456` | `3456x4608` |
| `4:3` | `1152x864` | `2304x1728` | `3456x2592` | `4608x3456` |
| `16:9` | `1312x736` | `2624x1472` | `3936x2208` | `5248x2944` |
| `9:16` | `736x1312` | `1472x2624` | `2208x3936` | `2944x5248` |
| `2:3` | `832x1248` | `1664x2496` | `2496x3744` | `3328x4992` |
| `3:2` | `1248x832` | `2496x1664` | `3744x2496` | `4992x3328` |
| `21:9` | `1568x672` | `3136x1344` | `4704x2016` | `6272x2688` |
### 使用注意
- 需要服务端配置 `AGNES_API_KEY`(默认走 `https://apihub.agnes-ai.com/v1`,可用 `AGNES_API_BASE_URL` 覆盖)
- 单次上游请求超时为 360 秒,建议客户端超时 ≥ 60s
- 上游请求遇到网络错误或 408/429/500/502/503/504 时,最多自动重试 3 次,并使用指数退避
- 图生图 / 多图合成的参考图必须是可公开访问的 HTTPS URL,否则请用 Data URI Base64
- 当前定价:免费(`$0 / 张`)
## 调用建议 ## 调用建议
- **优先用 Grok**:稳定出图,适合日常使用 - **优先用 Grok**:稳定出图,适合日常使用

File diff suppressed because one or more lines are too long

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""FastAPI 入口:AI 图片生成服务。"""
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, model_validator
from utils.mm_api_t2i import OUTPUT_DIR, PROVIDERS, generate_images
from utils.agnes_image import VALID_RATIOS, generate_images as agnes_generate_images
app = FastAPI(title="AI Image T2I API", version="1.0.0")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/output", StaticFiles(directory=OUTPUT_DIR), name="output")
class ImageGenerationRequest(BaseModel):
prompt: str = Field(min_length=1, description="图片提示词")
provider: str = Field(default="grok", pattern="^(grok|gpt)$", description="模型提供方")
model: str | None = Field(default=None, description="覆盖提供方默认模型")
size: str = Field(default="1024x1024", description="图片尺寸")
n: int = Field(default=1, ge=1, le=10, description="生成数量")
profile_name: str = Field(pattern=r"^[\w-]+$", description="调用方标识(必填),图片会保存到 output/<profile_name>/ 下")
@model_validator(mode="before")
@classmethod
def check_profile_name(cls, data):
if isinstance(data, dict) and not data.get("profile_name"):
raise ValueError("缺少 profile_name 参数,请在请求中带上你的 agent/profile 名称")
return data
class GeneratedImage(BaseModel):
filename: str
url: str
bytes: int
class ImageGenerationResponse(BaseModel):
provider: str
model: str
images: list[GeneratedImage]
@app.get("/health")
def health_check():
return {"status": "ok"}
@app.post("/images/generations", response_model=ImageGenerationResponse)
def generate_images_endpoint(request: ImageGenerationRequest):
result = generate_images(
provider=request.provider,
model=request.model,
prompt=request.prompt,
size=request.size,
n=request.n,
profile_name=request.profile_name,
)
return ImageGenerationResponse(**result)
class AgnesImageGenerationRequest(BaseModel):
prompt: str = Field(min_length=1, description="图片提示词")
size: str = Field(default="1K", description="输出尺寸档位: 1K / 2K / 3K / 4K,也支持精确尺寸如 1024x1024")
ratio: str | None = Field(default=None, description=f"宽高比,支持 {sorted(VALID_RATIOS)};与 size 档位配合使用")
images: list[str] | None = Field(default=None, description="图生图 / 多图合成的输入图像 URL 或 Data URI Base64")
profile_name: str = Field(pattern=r"^[\w-]+$", description="调用方标识(必填),图片会保存到 output/<profile_name>/ 下")
@model_validator(mode="before")
@classmethod
def check_profile_name(cls, data):
if isinstance(data, dict) and not data.get("profile_name"):
raise ValueError("缺少 profile_name 参数,请在请求中带上你的 agent/profile 名称")
return data
class AgnesImageGenerationResponse(BaseModel):
model: str
size: str
ratio: str
images: list[GeneratedImage]
@app.post("/agnes/images/generations", response_model=AgnesImageGenerationResponse)
def agnes_generate_images_endpoint(request: AgnesImageGenerationRequest):
result = agnes_generate_images(
prompt=request.prompt,
size=request.size,
ratio=request.ratio,
images=request.images,
profile_name=request.profile_name,
)
return AgnesImageGenerationResponse(**result)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

@ -0,0 +1,140 @@
"""Agnes Image 2.1 Flash 文生图 / 图生图 / 多图合成的核心逻辑。"""
import base64
import json
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from uuid import uuid4
from fastapi import HTTPException
API_BASE_URL = os.getenv("AGNES_API_BASE_URL", "https://apihub.agnes-ai.com/v1")
OUTPUT_DIR = Path("output")
MAX_RETRIES = 3
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
DEFAULT_MODEL = "agnes-image-2.1-flash"
VALID_RATIOS = {"1:1", "3:4", "4:3", "16:9", "9:16", "2:3", "3:2", "21:9"}
RATIO_DIMENSIONS = {
"1:1": {"1K": "1024x1024", "2K": "2048x2048", "3K": "3072x3072", "4K": "4096x4096"},
"3:4": {"1K": "864x1152", "2K": "1728x2304", "3K": "2592x3456", "4K": "3456x4608"},
"4:3": {"1K": "1152x864", "2K": "2304x1728", "3K": "3456x2592", "4K": "4608x3456"},
"16:9": {"1K": "1312x736", "2K": "2624x1472", "3K": "3936x2208", "4K": "5248x2944"},
"9:16": {"1K": "736x1312", "2K": "1472x2624", "3K": "2208x3936", "4K": "2944x5248"},
"2:3": {"1K": "832x1248", "2K": "1664x2496", "3K": "2496x3744", "4K": "3328x4992"},
"3:2": {"1K": "1248x832", "2K": "2496x1664", "3K": "3744x2496", "4K": "4992x3328"},
"21:9": {"1K": "1568x672", "2K": "3136x1344", "3K": "4704x2016", "4K": "6272x2688"},
}
def _resolve_size(size: str, ratio: str | None) -> str:
"""把 size 档位 (1K/2K/3K/4K) 配合 ratio 解析成实际像素尺寸,方便保存文件。"""
if ratio and ratio not in VALID_RATIOS:
raise HTTPException(status_code=400, detail=f"不支持的宽高比: {ratio}")
if size in {"1K", "2K", "3K", "4K"}:
if not ratio:
ratio = "1:1"
return RATIO_DIMENSIONS[ratio][size]
return size
def _build_payload(*, prompt: str, size: str, ratio: str | None, images: list[str] | None) -> dict:
"""组装请求体。注意 response_format 必须放在 extra_body 内。"""
payload: dict = {
"model": DEFAULT_MODEL,
"prompt": prompt,
"size": size,
}
extra_body: dict = {}
if ratio:
payload["ratio"] = ratio
if images:
extra_body["image"] = images
if extra_body:
payload["extra_body"] = extra_body
return payload
def _post_with_retry(url: str, headers: dict, payload: dict) -> dict:
"""调用 Agnes Image 接口,并对可恢复的上游错误进行指数退避重试。"""
data = json.dumps(payload).encode("utf-8")
for attempt in range(1, MAX_RETRIES + 1):
request = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=360) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
body = e.read().decode("utf-8", errors="replace")
raise HTTPException(status_code=502, detail=f"Agnes 图片服务错误: HTTP {e.code} {body}") from e
except (urllib.error.URLError, TimeoutError) as e:
if attempt == MAX_RETRIES:
raise HTTPException(status_code=504, detail="Agnes 图片服务连接或请求超时") from e
time.sleep(2 ** (attempt - 1))
def _download_image(url: str) -> bytes:
"""从 URL 或 data URI 下载图片。"""
if url.startswith("data:image/"):
try:
_, encoded = url.split(",", 1)
return base64.b64decode(encoded, validate=True)
except (ValueError, TypeError) as e:
raise HTTPException(status_code=502, detail="图片 data URL 格式无效") from e
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
for attempt in range(1, MAX_RETRIES + 1):
try:
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()
except urllib.error.HTTPError as e:
if e.code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
raise HTTPException(status_code=502, detail=f"图片 URL 下载失败: HTTP {e.code}") from e
except (urllib.error.URLError, TimeoutError) as e:
if attempt == MAX_RETRIES:
raise HTTPException(status_code=504, detail="图片 URL 下载超时或网络连接失败") from e
time.sleep(2 ** (attempt - 1))
def generate_images(
*,
prompt: str,
size: str,
ratio: str | None,
images: list[str] | None,
profile_name: str,
):
"""生成图片并保存到本地,返回结果字典。支持文生图 / 图生图 / 多图合成。"""
api_key = os.getenv("AGNES_API_KEY")
if not api_key:
raise HTTPException(status_code=500, detail="未配置环境变量 AGNES_API_KEY")
resolved_size = _resolve_size(size, ratio)
payload = _build_payload(prompt=prompt, size=size, ratio=ratio, images=images)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
response = _post_with_retry(f"{API_BASE_URL}/images/generations", headers, payload)
profile_dir = OUTPUT_DIR / profile_name
profile_dir.mkdir(parents=True, exist_ok=True)
saved = []
for item in response.get("data") or []:
try:
if item.get("b64_json"):
raw = base64.b64decode(item["b64_json"], validate=True)
elif item.get("url"):
raw = _download_image(item["url"])
else:
raise HTTPException(status_code=502, detail="Agnes 图片服务未返回 b64_json 或 url")
except ValueError as e:
raise HTTPException(status_code=502, detail="Agnes Base64 图片数据格式无效") from e
filename = f"{DEFAULT_MODEL}-{uuid4().hex}.png"
(profile_dir / filename).write_bytes(raw)
saved.append({"filename": filename, "url": f"/output/{profile_name}/{filename}", "bytes": len(raw)})
return {"model": DEFAULT_MODEL, "size": resolved_size, "ratio": ratio or "1:1", "images": saved}

@ -1,5 +1,4 @@
#!/usr/bin/env python3 """GPT Image 和 Grok Image 文生图的核心逻辑。"""
"""通过 FastAPI 提供 GPT Image 和 Grok Image 文生图服务。"""
import base64 import base64
import os import os
@ -9,18 +8,14 @@ import urllib.request
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
from dotenv import load_dotenv from fastapi import HTTPException
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from openai import APIConnectionError, APIStatusError, APITimeoutError, OpenAI from openai import APIConnectionError, APIStatusError, APITimeoutError, OpenAI
from pydantic import BaseModel, Field, model_validator
load_dotenv()
API_BASE_URL = os.getenv("IMAGE_API_BASE_URL", "https://api.slomerex.xyz/v1") API_BASE_URL = os.getenv("IMAGE_API_BASE_URL", "https://api.slomerex.xyz/v1")
OUTPUT_DIR = Path("output") OUTPUT_DIR = Path("output")
MAX_RETRIES = 3 MAX_RETRIES = 3
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504} RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
PROVIDERS = { PROVIDERS = {
"grok": { "grok": {
"api_key_env": "GROK_API_KEY", "api_key_env": "GROK_API_KEY",
@ -32,40 +27,8 @@ PROVIDERS = {
}, },
} }
app = FastAPI(title="AI Image T2I API", version="1.0.0")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/output", StaticFiles(directory=OUTPUT_DIR), name="output")
class ImageGenerationRequest(BaseModel):
prompt: str = Field(min_length=1, description="图片提示词")
provider: str = Field(default="grok", pattern="^(grok|gpt)$", description="模型提供方")
model: str | None = Field(default=None, description="覆盖提供方默认模型")
size: str = Field(default="1024x1024", description="图片尺寸")
n: int = Field(default=1, ge=1, le=10, description="生成数量")
profile_name: str = Field(pattern=r"^[\w-]+$", description="调用方标识(必填),图片会保存到 output/<profile_name>/ 下")
@model_validator(mode="before")
@classmethod
def check_profile_name(cls, data):
if isinstance(data, dict) and not data.get("profile_name"):
raise ValueError("缺少 profile_name 参数,请在请求中带上你的 agent/profile 名称")
return data
class GeneratedImage(BaseModel):
filename: str
url: str
bytes: int
class ImageGenerationResponse(BaseModel): def _generate_with_retry(client: OpenAI, *, model: str, prompt: str, size: str, n: int):
provider: str
model: str
images: list[GeneratedImage]
def generate_with_retry(client: OpenAI, *, model: str, prompt: str, size: str, n: int):
"""调用图片接口,并对可恢复的上游错误进行指数退避重试。""" """调用图片接口,并对可恢复的上游错误进行指数退避重试。"""
for attempt in range(1, MAX_RETRIES + 1): for attempt in range(1, MAX_RETRIES + 1):
try: try:
@ -80,7 +43,8 @@ def generate_with_retry(client: OpenAI, *, model: str, prompt: str, size: str, n
time.sleep(2 ** (attempt - 1)) time.sleep(2 ** (attempt - 1))
def download_image(url: str) -> bytes: def _download_image(url: str) -> bytes:
"""从 URL 或 data URI 下载图片。"""
if url.startswith("data:image/"): if url.startswith("data:image/"):
try: try:
_, encoded = url.split(",", 1) _, encoded = url.split(",", 1)
@ -102,29 +66,18 @@ def download_image(url: str) -> bytes:
time.sleep(2 ** (attempt - 1)) time.sleep(2 ** (attempt - 1))
@app.get("/health") def generate_images(*, provider: str, model: str | None, prompt: str, size: str, n: int, profile_name: str):
def health_check(): """生成图片并保存到本地,返回结果字典。"""
return {"status": "ok"} config = PROVIDERS[provider]
@app.post("/images/generations", response_model=ImageGenerationResponse)
def generate_images(request: ImageGenerationRequest):
config = PROVIDERS[request.provider]
api_key = os.getenv(config["api_key_env"]) api_key = os.getenv(config["api_key_env"])
if not api_key: if not api_key:
raise HTTPException(status_code=500, detail=f"未配置环境变量 {config['api_key_env']}") raise HTTPException(status_code=500, detail=f"未配置环境变量 {config['api_key_env']}")
model = request.model or config["model"] resolved_model = model or config["model"]
profile_dir = OUTPUT_DIR / request.profile_name profile_dir = OUTPUT_DIR / profile_name
profile_dir.mkdir(parents=True, exist_ok=True) profile_dir.mkdir(parents=True, exist_ok=True)
client = OpenAI(base_url=API_BASE_URL, api_key=api_key, timeout=120.0, max_retries=0) client = OpenAI(base_url=API_BASE_URL, api_key=api_key, timeout=120.0, max_retries=0)
response = generate_with_retry( response = _generate_with_retry(client, model=resolved_model, prompt=prompt, size=size, n=n)
client,
model=model,
prompt=request.prompt,
size=request.size,
n=request.n,
)
images = [] images = []
for item in response.data: for item in response.data:
@ -132,20 +85,14 @@ def generate_images(request: ImageGenerationRequest):
if item.b64_json: if item.b64_json:
raw = base64.b64decode(item.b64_json, validate=True) raw = base64.b64decode(item.b64_json, validate=True)
elif item.url: elif item.url:
raw = download_image(item.url) raw = _download_image(item.url)
else: else:
raise HTTPException(status_code=502, detail="上游图片服务未返回 b64_json 或 url") raise HTTPException(status_code=502, detail="上游图片服务未返回 b64_json 或 url")
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=502, detail="上游 Base64 图片数据格式无效") from e raise HTTPException(status_code=502, detail="上游 Base64 图片数据格式无效") from e
filename = f"{model}-{uuid4().hex}.png" filename = f"{resolved_model}-{uuid4().hex}.png"
(profile_dir / filename).write_bytes(raw) (profile_dir / filename).write_bytes(raw)
images.append(GeneratedImage(filename=filename, url=f"/output/{request.profile_name}/{filename}", bytes=len(raw))) images.append({"filename": filename, "url": f"/output/{profile_name}/{filename}", "bytes": len(raw)})
return ImageGenerationResponse(provider=request.provider, model=model, images=images)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) return {"provider": provider, "model": resolved_model, "images": images}
Loading…
Cancel
Save