feat(media): support GPT image output controls

This commit is contained in:
caoqianming 2026-07-31 17:32:15 +08:00
parent 59c238bd34
commit 75bb482015
8 changed files with 225 additions and 34 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.21 — 2026-07-31
- GPT 生图现在可以指定输出尺寸和质量,支持方图、横图、竖图及符合约束的自定义分辨率。
## 0.60.20 — 2026-07-31
- 修复「GPT 生图」因图像模型配置过期而无法生成图片的问题。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-31(修复 GPT 生图模型路由,bump 0.60.20)
最后更新:2026-07-31(GPT 生图支持尺寸与质量参数,bump 0.60.21)
---
@ -23,6 +23,7 @@
### 2026-07-31
- **07-31 / 0.60.21 / GPT Image 2 尺寸与质量参数**:`gpt_image` 开放 `size`(`auto` 或合法 `WIDTHxHEIGHT`)与 `quality`(`auto/low/medium/high`),按官方约束校验 16 倍数、3:1 比例、边长及总像素范围,并从 PNG IHDR 回填实际尺寸到 banner、meta 与 usage真实 `1024x1024 + low` 请求返回同尺寸 PNG。同步确认 unifyllm `/images/edits` 的 JSON、multipart 及 Responses image_generation 三条改图路径当前均不可用,故不暴露必失败的参考图参数,继续明确引导改图切 Seedream手工脚本显式 mock 用量记录,杜绝真实探针触库。相关 41 项 unittest 通过1 项测试库门控跳过Python 编译与 diff 格式检查通过;无 schema、migration 或 HTTP API 变化。
- **07-31 / 0.60.20 / GPT 生图模型路由修复**:unifyllm 的 Images 端点已由历史文本渠道名切换为专用图像模型,媒体配置同步改用当前 `/v1/models` 开放的 `gpt-image-2`,保持既有 `/images/generations`、prompt-only 工具契约与 `b64_json` 解析不变;手工干跑增加模型 ID 回归断言。真实 OpenAI 格式请求返回 HTTP 200 及完整 base64 图像,相关 16 项 unittest 通过1 项按环境跳过diff 格式检查通过;未连接数据库、无 schema、migration 或 API 变化。
- **07-31 / 0.60.19 / 清空对话 500 修复**:清空接口补查后续标题重置逻辑所需的 `title_source`,修复仅查询 `run_status` 后访问缺失字段导致的 `AttributeError` 与事务回滚;原有 DB 路由用例已覆盖自动标题和人工标题两类清空行为,无 schema、migration、API 或数据语义变化。16 项无 DB 路由测试、Python 编译、致命静态检查与 diff 格式检查通过;未设置 `ZCBOT_TEST_DB_URL`DB 路由测试按安全门控跳过。
- **07-31 / 0.60.18 / 任务列表默认按最近更新时间排序**:普通任务列表的前后端默认排序统一由创建时间倒序改为更新时间倒序,最近继续处理的对话会回到顶部;显式 `ordering=-created_at` 等既有排序参数和全部下拉选项保持兼容。新增默认值、非法参数回退及创建时间兼容 3 项单元测试Python/JavaScript 语法与 diff 格式检查通过;无 schema、migration 或依赖变化。

View File

@ -7,10 +7,12 @@
# - 2026-07-10 网关曾要求用 gpt-5.6-sol 渠道名路由;2026-07-31 起 images 端点
# 严格要求图像模型,当前 /v1/models 开放 gpt-image-2。文本模型 gpt-5.6-sol 会返回
# HTTP 400 "images endpoint requires an image model"。
# - 旧 gpt-5.6-sol 渠道曾忽略 size / quality。切换 gpt-image-2 后先保持 tool 的
# prompt-only 对外契约;尺寸/质量能力需单独验证后再开放,避免未经验证改变现有行为。
# - 不支持改图(i2i 需 /images/edits multipart,v1 不接);改图场景切回豆包 Seedream
# - 单张耗时 ~35-40s(慢于 seedream 的 3-5s)
# - gpt-image-2 支持 size / quality:尺寸可用 auto 或满足约束的 WIDTHxHEIGHT,
# quality 可用 auto / low / medium / high。输出固定 PNG,透明背景当前模型不支持。
# - 2026-07-31 实测网关 /images/edits 暂不可用:官方 JSON 返回
# convert_request_failed,官方 multipart(image[] / image)均返回 NextPart: EOF;
# Responses API 也静默丢弃强制 image_generation tool。网关修复前不暴露改图参数。
# - 复杂图片可能需 ~2min(慢于 seedream)
# - 价格:网关未公布价目,price 暂 0(usage tokens 记进 units,拿到价目后回填对账)
# - 服务器需代理出口(直连 unifyllm.ai TLS 失败),同文本模型
@ -22,5 +24,7 @@ image:
model_id: gpt-image-2
display_name: GPT 生图
endpoint: /images/generations
default_size: auto
default_quality: auto
price_cny_per_image: 0 # 网关价目未知,成本先记 0;拿到价目改这里 + 重启
request_timeout_s: 300 # 实测 ~40s/张,给足余量
request_timeout_s: 300 # 复杂图可能 ~2min,给足余量

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.60.20"
__version__ = "0.60.21"

View File

@ -64,12 +64,12 @@ _MEDIA_SEEDREAM_SEG = """\
- **调用前必须先 `load_skill('imagegen')`** skill 里有何时该用 / 该不该用 mermaid 替代 / 用户描述模糊度诊断 / 一次性追问范式 / prompt 装配 / 改图(i2i)范式 / 失败解药全套引导**不要拿用户原话直接当 prompt tool** 容易烧 ¥0.22 在错的方向上
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调用户消息里出现 `[用户上传的参考图] <路径>` = 用户贴了图,要看图 / 改图时用那个路径"""
_MEDIA_GPT_IMAGE_SEG = """\
- `gpt_image` GPT 图像生成( run 用户在顶栏选了GPT 生图,seedream 不可用;其他地方提到 seedream 的指引按 gpt_image 理解)产物自动落 `<task_dir>/figures/`,单张 **~40s**(,调用前告知用户稍等)
- **只支持文生图**:不支持改图(i2i)不支持指定尺寸/比例(尺寸由模型按画面自动决定,常见 1024-1536 边长)用户要改已有图 / 要精确尺寸或宽高比 直接说明并建议顶栏把图像模型切回豆包 Seedream,**不要硬用文生图凑**
- **调用前必须先 `load_skill('imagegen')`** 其中何时该用 / mermaid 反向选型 / 模糊度诊断 / prompt 装配 / 先给用户过目再调的流程完全适用;参数以本工具 schema 为准(只有 prompt,size/watermark/search 段忽略)
- `gpt_image` GPT 图像生成( run 用户在顶栏选了GPT 生图,seedream 不可用;其他地方提到 seedream 的指引按 gpt_image 理解)产物自动落 `<task_dir>/figures/`,复杂图可能需 **~2min**(,调用前告知用户稍等)
- **仅文生图**:支持 `size`(`auto` 或合法 `WIDTHxHEIGHT`) `quality`(`auto/low/medium/high`)当前网关改图端点不可用;用户要修改已有图片时明确说明,并建议在顶栏切回豆包 Seedream
- **调用前必须先 `load_skill('imagegen')`** 其中何时该用 / mermaid 反向选型 / 模糊度诊断 / prompt 装配 / 先给用户过目再调的流程完全适用;参数以本工具 schema 为准
- 兜底硬约束(即使没 load skill 也守):用户没主动要图就别装饰性生成;同一目的不满意**不要连发**,先口头校准 prompt 再调"""
_MEDIA_DIAGRAM_FORK_SEG = """\
- **""先分岔(mermaid vs 生图)**:用户要**流程图 / 架构图 / 技术路线图 / 时序图**这类结构图时,**不要默默替他选路线** 先用 `ask_user` 让用户在两条路线里点选:mermaid 矢量图(结构清晰文字准可编辑零成本);生图模型视觉版(有质感 / 视觉冲击,但中文标签易乱码位图不可后期改字,seedream ¥0.22 / GPT 生图 ~40s)选②后 `load_skill('imagegen')` 再走**免问直走的例外**:用户已点名工具("用 mermaid" / "用生图画" / "用 GPT 画") 照办;本次对话里用户已选过路线 沿用不再问;paper / proposal skill 管线内部要求 mermaid 配图的 skill 规定走"""
- **""先分岔(mermaid vs 生图)**:用户要**流程图 / 架构图 / 技术路线图 / 时序图**这类结构图时,**不要默默替他选路线** 先用 `ask_user` 让用户在两条路线里点选:mermaid 矢量图(结构清晰文字准可编辑零成本);生图模型视觉版(有质感 / 视觉冲击,但中文标签易乱码位图不可后期改字,seedream ¥0.22 / GPT 生图复杂图可能 ~2min)选②后 `load_skill('imagegen')` 再走**免问直走的例外**:用户已点名工具("用 mermaid" / "用生图画" / "用 GPT 画") 照办;本次对话里用户已选过路线 沿用不再问;paper / proposal skill 管线内部要求 mermaid 配图的 skill 规定走"""
_MEDIA_SEEDANCE_SEG = """\
- `seedance` 豆包视频生成(Seedance 2.0 Fast),支持文生视频单图首帧和最多 9 张多图参考生视频异步任务,** 30-90s 出片**;产物自动落 `<task_dir>/videos/`每次 **¥1.86 **(480p 4s)~ **¥12+**(720p 15s),比图贵 10 倍以上触发词:视频 / 动画 / 动起来 / 做个 video / 镜头 / 短片 / 演示视频 / 动效
- **调用前必须先 `load_skill('videogen')`** skill 里有6 维诊断(含运动维必填)/ seedream/mermaid 反向选型 / prompt 装配 / 参数取舍(时长/分辨率/比例直接决定钱)/ 失败解药全套引导视频比图贵 10 倍且 90s 等待,绝对不要拿用户原话当 prompt 直接调

View File

@ -11,6 +11,7 @@ import sys
import tempfile
import uuid
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
@ -59,8 +60,14 @@ def main() -> int:
# prompt 媒体段拼装
blk = _media_tools_block(ark is not None, "gpt_image")
ok = "`gpt_image`" in blk and "`seedream`" not in blk.split("\n")[0] and "reference_images" not in blk.split("- `gpt_image`")[1].split("- `")[0]
print(f"[{'OK' if ok else 'FAIL'}] media block(gpt_image) mentions gpt_image, no i2i params")
gpt_seg = blk.split("- `gpt_image`")[1].split("- `")[0]
ok = (
"`gpt_image`" in blk
and "`seedream`" not in blk.split("\n")[0]
and "size" in gpt_seg
and "quality" in gpt_seg
)
print(f"[{'OK' if ok else 'FAIL'}] media block(gpt_image) includes size/quality guidance")
fails += 0 if ok else 1
blk2 = _media_tools_block(ark is not None, "seedream")
ok = ("- `seedream`" in blk2) and ("- `gpt_image`" not in blk2)
@ -74,7 +81,7 @@ def main() -> int:
if dry:
return 1 if fails else 0
# 真实调用:临时 working_dir,不限额(daily_limit=0),无 DB(record 失败仅打印)
# 真实调用:临时 working_dir,不限额(daily_limit=0),显式 mock 用量写入,绝不碰 DB。
from tools.gpt_image import GptImageTool
prov, key, cfg, pcfg = _choose_image_variant(ark, gw, "gpt_image")
@ -86,7 +93,12 @@ def main() -> int:
working_dir=wd, task_id=uuid.uuid4(), user_id=uuid.uuid4(),
base_dir=Path(td), user_root=Path(td), daily_limit=0,
)
result = tool.execute(prompt="A minimalist flat-design icon of a cement mixer truck, orange and grey")
with patch("tools.gpt_image.record_usage_safe"):
result = tool.execute(
prompt="A minimalist flat-design icon of a cement mixer truck, orange and grey",
size="1024x1024",
quality="low",
)
first = result.split("\n")[0]
print(f"[info] result first line: {first}")
pngs = list(wd.glob("figures/*.png"))

105
tests/test_gpt_image.py Normal file
View File

@ -0,0 +1,105 @@
import base64
import struct
import tempfile
import unittest
import uuid
from pathlib import Path
from unittest.mock import patch
from core.ark_client import ArkConfig
from tools.gpt_image import GptImageTool
def _png_stub(width: int = 1536, height: int = 864) -> bytes:
return b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + struct.pack(">II", width, height)
class _FakeArkClient:
json_call = None
def __init__(self, *_args, **_kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *_args):
pass
def post_json(self, endpoint, body, *, timeout_s=None):
type(self).json_call = (endpoint, body, timeout_s)
return {
"data": [{"b64_json": base64.b64encode(_png_stub()).decode("ascii")}],
"usage": {"output_tokens": 123},
}
class GptImageToolTests(unittest.TestCase):
def setUp(self):
_FakeArkClient.json_call = None
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.working_dir = self.root / "task"
self.working_dir.mkdir()
self.cfg = {
"model_id": "gpt-image-2",
"endpoint": "/images/generations",
"default_size": "auto",
"default_quality": "auto",
"request_timeout_s": 300,
"price_cny_per_image": 0,
}
self.tool = GptImageTool(
gw_cfg=ArkConfig(api_key="test", base_url="https://example.test/v1", raw={}),
image_variant_cfg=self.cfg,
variant_key="gpt_image",
working_dir=self.working_dir,
task_id=uuid.uuid4(),
user_id=uuid.uuid4(),
base_dir=self.working_dir,
user_root=self.root,
daily_limit=0,
)
def tearDown(self):
self.tmp.cleanup()
def _execute(self, **kwargs):
with (
patch("tools.gpt_image.ArkClient", _FakeArkClient),
patch("tools.gpt_image.quota_gate", return_value=""),
patch("tools.gpt_image.record_usage_safe"),
):
return self.tool.execute(**kwargs)
def test_text_to_image_forwards_size_and_quality(self):
result = self._execute(
prompt="draw a materials lab",
size="1536x864",
quality="high",
)
self.assertTrue(result.startswith("[gpt_image]"))
endpoint, body, _timeout = _FakeArkClient.json_call
self.assertEqual(endpoint, "/images/generations")
self.assertEqual(body["size"], "1536x864")
self.assertEqual(body["quality"], "high")
self.assertIn("size=1536x864", result)
self.assertIn("quality=high", result)
def test_size_validation(self):
self.assertEqual(self.tool._normalize_size("auto"), ("auto", ""))
self.assertEqual(self.tool._normalize_size("1536×864"), ("1536x864", ""))
for value in ("1000x1000", "4096x1024", "3072x512", "640x640", "bad"):
with self.subTest(value=value):
_size, error = self.tool._normalize_size(value)
self.assertTrue(error.startswith("[Error]"))
def test_quality_validation(self):
result = self._execute(prompt="draw", quality="ultra")
self.assertEqual(result, "[Error] quality 必须是 auto / low / medium / high")
self.assertIsNone(_FakeArkClient.json_call)
if __name__ == "__main__":
unittest.main()

View File

@ -1,11 +1,11 @@
"""gpt_image: 调 unifyllm 网关的 OpenAI Images API 生图,产物落 working_dir/figures/。
第二图像后端(第一个是豆包 seedream):模型 ID + 单价全在 `config/media/unifyllm.yaml`,
tool 只装配 seedream 的差异(网关实测, yaml 头注释):
- 只支持文生图,**不支持改图(i2i)/ 自定尺寸 / 质量档**(网关忽略 size/quality 参数,
实际尺寸由上游模型按画面自动决定),所以参数只有 prompt;
tool 只装配:
- 文生图走 /images/generations JSON,gpt-image-2 支持自定义尺寸和质量档;
- 当前 unifyllm 网关的 /images/edits 不可用,暂不暴露改图参数;
- 响应直接返 b64_json,无需二次下载;
- 单张 ~35-40s(慢于 seedream 3-5s)
- 复杂图片可能需 ~2min
完成后:
- 图片落 `<working_dir>/figures/<YYYYMMDD-HHMMSS>-<rand6>.png` + 同名 `.meta.json`
- usage_events kind="image" 一行(model_profile="unifyllm.<variant>",
@ -15,6 +15,8 @@ from __future__ import annotations
import base64
import json
import re
import struct
import time
from datetime import datetime
from pathlib import Path
@ -31,12 +33,10 @@ from .media_common import quota_gate, record_usage_safe, stamped_path, write_met
class GptImageTool(Tool):
name = "gpt_image"
description = (
"Generate an image (text-to-image only) via the GPT image backend, "
"saved to working_dir/figures/. Slower than seedream (~40-60s/image); output size is "
"chosen automatically by the model and CANNOT be specified. Does NOT support "
"image-to-image editing — if the user wants to modify an existing image or needs an "
"exact size/aspect ratio, tell them to switch the image model back to 豆包 Seedream "
"(顶栏图像模型下拉). Don't generate decoratively — only when the user actually "
"Generate an image via GPT Image, saved to working_dir/figures/. Supports custom size "
"and quality. Complex images may take up to ~2 minutes. Image-to-image editing is not "
"available through the current gateway; ask the user to switch to 豆包 Seedream for "
"editing an existing image. Don't generate decoratively — only when the user actually "
"wants an image. Returns the saved relative path."
)
parameters = {
@ -44,7 +44,20 @@ class GptImageTool(Tool):
"properties": {
"prompt": {
"type": "string",
"description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。尺寸/比例不可控,别在 prompt 里承诺具体分辨率。",
"description": "中文或英文都行,详尽描述画面(主体/风格/光线/构图)。",
},
"size": {
"type": "string",
"description": (
"输出尺寸。默认 auto;也可传 WIDTHxHEIGHT,如 1024x1024、1536x864、"
"2048x1152。宽高须为 16 的倍数,比例不超过 3:1,总像素 "
"655360-8294400,单边不超过 3840。"
),
},
"quality": {
"type": "string",
"enum": ["auto", "low", "medium", "high"],
"description": "生成质量。默认 auto;草稿用 low,正式产物用 medium 或 high。",
},
},
"required": ["prompt"],
@ -72,10 +85,27 @@ class GptImageTool(Tool):
self.user_id = user_id
self.daily_limit = int(daily_limit) # 0 / 负 = 不限;与 seedream 共享 kind="image" 每日配额
def execute(self, prompt: str) -> str:
def execute(
self,
prompt: str,
size: Optional[str] = None,
quality: Optional[str] = None,
) -> str:
if not (prompt or "").strip():
return "[Error] prompt 不能为空"
cfg = self.cfg
chosen_size, size_err = self._normalize_size(
size if size is not None else cfg.get("default_size", "auto")
)
if size_err:
return size_err
chosen_quality = str(
quality if quality is not None else cfg.get("default_quality", "auto")
).strip().lower()
if chosen_quality not in {"auto", "low", "medium", "high"}:
return "[Error] quality 必须是 auto / low / medium / high"
# 每账号每日配额(kind="image" 与 seedream 同口径合计)
quota_err = quota_gate(
self.user_id, kind="image", limit=self.daily_limit, what="图片生成", noun="",
@ -83,13 +113,18 @@ class GptImageTool(Tool):
if quota_err:
return quota_err
cfg = self.cfg
model_id = cfg["model_id"]
endpoint = cfg.get("endpoint", "/images/generations")
timeout_s = float(cfg.get("request_timeout_s", 300))
price = float(cfg.get("price_cny_per_image", 0))
body = {"model": model_id, "prompt": prompt, "n": 1}
body = {
"model": model_id,
"prompt": prompt,
"n": 1,
"size": chosen_size,
"quality": chosen_quality,
}
t0 = time.monotonic()
try:
@ -114,8 +149,8 @@ class GptImageTool(Tool):
elapsed = time.monotonic() - t0
# 网关回填的实际尺寸/质量/tokens(价目未知期成本记 price snapshot,tokens 留对账)
actual_size = str(resp.get("size") or "")
quality = str(resp.get("quality") or "")
actual_size = self._png_size(img_bytes) or str(resp.get("size") or chosen_size)
actual_quality = str(resp.get("quality") or chosen_quality)
usage = resp.get("usage") or {}
output_tokens = int(usage.get("output_tokens") or 0)
@ -123,7 +158,9 @@ class GptImageTool(Tool):
"prompt": prompt,
"model_id": model_id,
"size": actual_size,
"quality": quality,
"requested_size": chosen_size,
"quality": actual_quality,
"requested_quality": chosen_quality,
"mode": "t2i",
"cost_cny": price,
"output_tokens": output_tokens,
@ -140,7 +177,7 @@ class GptImageTool(Tool):
n_images=1,
size=actual_size,
price_cny_per_image=price,
extra_units={"output_tokens": output_tokens, "quality": quality},
extra_units={"output_tokens": output_tokens, "quality": actual_quality},
)
disp = self._display(dest_png)
@ -148,7 +185,35 @@ class GptImageTool(Tool):
# 价格未知(price=0)时不放 cost 段,避免"¥0.00 = 免费"的误导。
cost_seg = f" · cost=¥{price:.2f}" if price > 0 else ""
return (
f"[gpt_image] model={model_id} · size={actual_size}{cost_seg} · elapsed={elapsed:.1f}s\n"
f"[gpt_image] model={model_id} · size={actual_size} · quality={actual_quality}"
f"{cost_seg} · elapsed={elapsed:.1f}s\n"
f"saved: {disp}\n"
f"prompt={prompt!r}"
)
@staticmethod
def _normalize_size(raw: object) -> tuple[str, str]:
value = str(raw or "auto").strip().lower().replace("×", "x")
if value == "auto":
return "auto", ""
match = re.fullmatch(r"([1-9]\d*)x([1-9]\d*)", value)
if not match:
return "", "[Error] size 必须是 auto 或 WIDTHxHEIGHT,例如 1536x864"
width, height = (int(match.group(1)), int(match.group(2)))
pixels = width * height
if width % 16 or height % 16:
return "", "[Error] size 的宽和高都必须是 16 的倍数"
if max(width, height) > 3840:
return "", "[Error] size 单边不能超过 3840"
if max(width, height) > min(width, height) * 3:
return "", "[Error] size 宽高比不能超过 3:1"
if not 655_360 <= pixels <= 8_294_400:
return "", "[Error] size 总像素必须在 655360 到 8294400 之间"
return f"{width}x{height}", ""
@staticmethod
def _png_size(data: bytes) -> str:
if len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n" and data[12:16] == b"IHDR":
width, height = struct.unpack(">II", data[16:24])
return f"{width}x{height}"
return ""