feat: AI 生图、图片下载优化

This commit is contained in:
Joey
2026-08-29 17:13:06 +08:00
parent a77ec26a02
commit 4ee667142b
20 changed files with 683 additions and 248 deletions
+111 -3
View File
@@ -61,9 +61,11 @@ def _raise_api_error(resp, provider: str):
raise ApiError(f"{provider} API HTTP {resp.status_code} — {detail}", resp.status_code)
try:
body = resp.json()
detail = f"{body.get('code', '')}: {body.get('message', '')}".strip(': ')
except Exception: # noqa: BLE001
detail = text[:200]
raise ApiError(f"{provider} API HTTP {resp.status_code} — {text[:200]}", resp.status_code)
# 兼容两种错误形状:顶层 {code, message}(豆包/通义)与嵌套 {error: {code, message}}(new_api 系中转)
err = body.get("error") if isinstance(body.get("error"), dict) else body
detail = f"{err.get('code', '')}: {err.get('message', '')}".strip(': ')
raise ApiError(f"{provider} API HTTP {resp.status_code} — {detail or '无错误详情'}", resp.status_code)
# 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面)
@@ -80,6 +82,9 @@ def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "")
if provider == "rightapi":
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
return "1536x2048" if ratio == "3:4" else "2048x2048"
if provider == "pcore":
# pcore 的 size 是枚举(默认 1024x1024),不接受自定义尺寸;用 gpt-image 枚举:竖版 1024x1536(近似 3:4)
return "1024x1536" if ratio == "3:4" else "1024x1024"
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
if model.startswith("wan2.6"):
@@ -379,7 +384,110 @@ async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "204
raise last_exc # type: ignore[misc]
GENERATORS = {"doubao": generate_doubao, "tongyi": generate_tongyi, "rightapi": generate_rightapi}
# ── Provider:Pcore(pcore.ai,OpenAI 兼容异步图片协议)──────────────────
async def _pcore_poll_task(client: httpx.AsyncClient, headers: dict, base: str,
kind: str, task_id: str, max_wait: int) -> dict:
"""轮询 Pcore 任务:GET {base}/v1/images/{kind}/{task_id},kind ∈ generations | edits。
状态机:queued / in_progress → 继续;completed / succeeded → 返回;
failed / error / cancelled → 抛错。
"""
poll_url = f"{base}/v1/images/{kind}/{task_id}"
elapsed, interval = 0, 3
while elapsed < max_wait:
resp = await client.get(poll_url, headers=headers, timeout=30)
_raise_api_error(resp, "Pcore")
result = resp.json()
status = result.get("status", "")
if status in ("failed", "error", "cancelled"):
err = result.get("error") or {}
raise RuntimeError(f"Pcore 任务失败: {err.get('message') or result}")
if status in ("completed", "succeeded"):
return result
await asyncio.sleep(interval)
elapsed += interval
interval = min(interval + 2, 10)
raise TimeoutError(f"Pcore 异步任务超时 ({max_wait}s): task_id={task_id}")
async def _pcore_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
"""Pcore:无参考图走 /v1/images/generations,带参考图走 /v1/images/edits(JSON,data-URI)。
两者均为异步:提交返回任务 id → 轮询对应资源路径 → 完成后 data[0].url 取图。
完成形状与 RightAPI 同为 Images 协议,复用 _rightapi_extract_image。
"""
base = s.pcore_base_url.rstrip("/")
headers = {"Authorization": f"Bearer {s.pcore_api_key}"}
kind = "edits" if ref_images else "generations"
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
if ref_images:
# pcore 的 edits 要求 images 为 [{image_url: ...}] 对象数组(实测纯字符串报 images[].image_url is required)
body["images"] = [{"image_url": await _resolve_ref(u)} for u in ref_images]
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
resp = await client.post(
f"{base}/v1/images/{kind}",
headers={**headers, "Content-Type": "application/json"},
json=body,
)
_raise_api_error(resp, "Pcore")
submitted = resp.json()
# new_api 系中转常见「HTTP 200 + 错误体」,需主动检查 error 字段
if isinstance(submitted, dict) and submitted.get("error"):
err = submitted["error"] or {}
raise RuntimeError(f"Pcore 提交失败: {err.get('message') or submitted}")
task_id = submitted.get("task_id") or submitted.get("id") or ""
if not task_id:
raise RuntimeError(f"Pcore 提交未返回任务 id: {submitted}")
result = await _pcore_poll_task(client, headers, base, kind, task_id, s.poll_max_wait)
img_kind, payload = _rightapi_extract_image(result)
if img_kind == "b64" and payload:
return base64.b64decode(payload.split(",", 1)[-1] if "," in payload else payload)
if img_kind == "url" and payload:
dl = await client.get(payload, timeout=s.request_timeout)
dl.raise_for_status()
return dl.content
raise RuntimeError(f"Pcore 任务完成但没有图片数据: {result}")
async def generate_pcore(prompt: str, ref_images: list[str], size: str = "1536x2048", model: str | None = None) -> bytes:
"""带重试的 Pcore 入口:429/5xx/超时按递增间隔重试(与 RightAPI 同策略)。"""
s = get_settings()
if not s.pcore_api_key:
raise RuntimeError("未配置 PCORE_API_KEY(.env)")
model = model or "gpt-image-2"
attempts = max(1, s.pcore_max_retries)
last_exc: Exception | None = None
for i in range(attempts):
try:
return await _pcore_request(s, prompt, ref_images, size, model)
except ApiError as exc:
last_exc = exc
if exc.status not in RETRYABLE_STATUS:
raise
# 通道不存在(模型未开通)重试无意义,立即失败
if "no available channel" in str(exc).lower() or "model_not_found" in str(exc).lower():
raise
except (httpx.TimeoutException, httpx.TransportError) as exc:
last_exc = exc # 网络抖动/超时可重试
if i == attempts - 1:
break
wait = s.pcore_retry_wait * (2 ** i)
log.warning("Pcore 第 %d/%d 次请求失败(%s),%ds 后重试", i + 1, attempts, last_exc, wait)
await asyncio.sleep(wait)
raise last_exc # type: ignore[misc]
GENERATORS = {
"doubao": generate_doubao,
"tongyi": generate_tongyi,
"rightapi": generate_rightapi,
"pcore": generate_pcore,
}
# ── 任务执行器 ────────────────────────────────────────────────────────────