Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PicGen Console

一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.57**。它把
一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.58**。它把
`/v1/images/generations`、`/v1/images/edits` 与 `/v1/responses`(含 `image_generation` 工具)
包装成统一可观测的代理,前端是一套零依赖的 Web 控制台。

Expand All @@ -14,7 +14,9 @@

![PicGen Console 主程序界面](demo1.png)

## 0.1.57 主要特性
## 0.1.58 主要特性

- **Responses 候选数量收敛**:强制使用图像工具、关闭并行工具调用并把请求数量写入提示词;上游仍越界时在落盘前按 `sample_count` 截断,避免单图任务意外生成 6 张候选并拖到数百秒,同时不再为非官方 `n` 参数自动重发付费请求。

- **简洁模式尺寸提示**:上游返回不同画幅时继续保留完整原图,并在结果按钮上方直接显示请求尺寸、实际尺寸和未缩放说明;提示随结果持久化,刷新后仍可见。

Expand Down Expand Up @@ -107,10 +109,10 @@ PICGEN_LOG_FORMAT=json \
### Docker

```bash
docker build -t minorli/picgen:0.1.57 .
docker build -t minorli/picgen:0.1.58 .
docker run --rm -p 8000:8000 \
-v picgen-data:/app/data \
minorli/picgen:0.1.57
minorli/picgen:0.1.58
```

或:
Expand All @@ -125,10 +127,10 @@ docker compose up -d
./scripts/docker-build-push.sh
```

默认会构建并推送 `minorli/picgen:0.1.57`。也可以覆盖:
默认会构建并推送 `minorli/picgen:0.1.58`。也可以覆盖:

```bash
IMAGE=minorli/picgen VERSION=0.1.57 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh
IMAGE=minorli/picgen VERSION=0.1.58 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh
```

镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root
Expand Down Expand Up @@ -241,7 +243,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到

## 图像通道

PicGen 0.1.57 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:
PicGen 0.1.58 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:

| 用户操作 | 默认接口 | 默认模型 |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
picgen:
image: minorli/picgen:0.1.57
image: minorli/picgen:0.1.58
build:
context: .
ports:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "picgen"
version = "0.1.57"
version = "0.1.58"
description = "Enterprise-grade local web console for OpenAI-compatible image generation and editing APIs."
readme = "README.md"
requires-python = ">=3.12"
Expand Down
2 changes: 1 addition & 1 deletion scripts/docker-build-push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
set -euo pipefail

IMAGE="${IMAGE:-minorli/picgen}"
VERSION="${VERSION:-0.1.57}"
VERSION="${VERSION:-0.1.58}"
PLATFORM="${PLATFORM:-linux/amd64}"

docker buildx build \
Expand Down
45 changes: 16 additions & 29 deletions src/picgen/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,8 @@ async def _generate_itinerary_artwork(
"instructions": ITINERARY_ARTWORK_INSTRUCTIONS,
**_responses_reasoning_options(model, settings.default_responses_reasoning_effort),
"stream": True,
"parallel_tool_calls": False,
"tool_choice": {"type": "image_generation"},
"input": [{"role": "user", "content": content}],
"tools": [tool],
}
Expand Down Expand Up @@ -4925,8 +4927,6 @@ async def handle_responses_image(
tool: dict[str, Any] = {"type": "image_generation"}
if size and size != "auto":
tool["size"] = size
if parsed.sample_count > 1:
tool["n"] = parsed.sample_count
for key in ("quality", "background", "output_format", "output_compression", "moderation"):
if key in image_options:
tool[key] = image_options[key]
Expand All @@ -4937,6 +4937,12 @@ async def handle_responses_image(
tool["input_image_mask"] = {"image_url": f"data:{mask_mime};base64,{mask_b64}"}

guarded_prompt = append_restricted_destination_guard(effective_prompt)
guarded_prompt = (
f"{guarded_prompt.rstrip()}\n\n"
f"图像数量要求:只调用 image_generation 工具 {parsed.sample_count} 次,"
f"每次只生成一张,最终恰好返回 {parsed.sample_count} 张图片;"
"不要额外生成备选方案、变体、草稿或候选图。"
)
if strict_size and size and size != "auto":
# The image_generation tool's `size` param is treated as advisory by
# some gateways (they "lazily" return a downscaled canvas). Restating
Expand All @@ -4951,6 +4957,11 @@ async def handle_responses_image(
"instructions": RESPONSES_IMAGE_INSTRUCTIONS,
**reasoning_options,
"stream": True,
# The default compatible gateway rejects the official max_tool_calls
# field. Serial forced calls plus the explicit count instruction is the
# strongest control it accepts; the finalize path also caps overflow.
"parallel_tool_calls": False,
"tool_choice": {"type": "image_generation"},
"input": [
{
"role": "user",
Expand All @@ -4965,33 +4976,9 @@ async def handle_responses_image(
}

async def _run_responses_upstream() -> dict[str, Any]:
try:
return await client.run_responses(
endpoint_url, api_key, upstream_payload, settings.upstream_user_agent
)
except APIError as exc:
# `n` is not part of the official image_generation tool schema; strict
# upstreams reject the whole request. Mirror the Images-API fallback:
# retry once without it when the error clearly points at the field.
if (
"n" in tool
and exc.status == HTTPStatus.BAD_REQUEST
and _error_mentions_sample_count(exc)
):
log_event(
logger,
logging.WARNING,
"responses_image_sample_count_fallback",
endpoint_url=endpoint_url,
sample_count=parsed.sample_count,
status=exc.status,
message=exc.message,
)
tool.pop("n", None)
return await client.run_responses(
endpoint_url, api_key, upstream_payload, settings.upstream_user_agent
)
raise
return await client.run_responses(
endpoint_url, api_key, upstream_payload, settings.upstream_user_agent
)

metadata = request_metadata({**payload, **image_options}, size=size)
if itinerary_id:
Expand Down
12 changes: 12 additions & 0 deletions src/picgen/upstream/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,8 @@ def prepare_image_payload(

data_items = upstream.get("data")
candidate_items = [item for item in data_items if isinstance(item, dict)] if isinstance(data_items, list) else []
requested_count = save_context.get("sample_count")
candidate_limit = max(1, int(requested_count)) if requested_count is not None else None
# Process candidates independently: a single sample that fails to download or
# save must not discard the others. Only surface an error if every candidate
# failed (otherwise the customer silently loses good images they asked for).
Expand Down Expand Up @@ -411,6 +413,16 @@ def prepare_image_payload(
):
continue
images.append(payload)
if candidate_limit is not None and len(images) >= candidate_limit:
if index + 1 < len(candidate_items):
log_event(
logger,
logging.WARNING,
"upstream_candidate_count_capped",
requested_count=candidate_limit,
upstream_count=len(candidate_items),
)
break
if not images and candidate_errors:
raise candidate_errors[0]
first_image = images[0] if images else {}
Expand Down
4 changes: 2 additions & 2 deletions static/app.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.57"
import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.58"
import {
DEFAULT_RESPONSES_MODEL,
RESPONSES_MODEL_STORAGE_VERSION,
RESPONSES_REASONING_STORAGE_VERSION,
migrateStoredResponsesReasoningSettings,
migrateStoredResponsesSettings,
} from "./responses-settings.mjs?v=0.1.57"
} from "./responses-settings.mjs?v=0.1.58"

const RESPONSES_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max", "ultra"])
const DEFAULT_RESPONSES_REASONING_EFFORT = "xhigh"
Expand Down
6 changes: 3 additions & 3 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PicGen Console</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23108566'/%3E%3Cpath d='M32 13l16 9v20l-16 9-16-9V22l16-9z' fill='%23ecfdf5'/%3E%3Ccircle cx='32' cy='32' r='8' fill='%23108566'/%3E%3C/svg%3E">
<link rel="stylesheet" href="styles.css?v=0.1.57">
<link rel="stylesheet" href="styles.css?v=0.1.58">
</head>
<body class="auth-gate">
<div id="authOverlay" class="auth-page" aria-hidden="false">
Expand Down Expand Up @@ -1046,7 +1046,7 @@ <h2>管理员高级设置</h2>
</div>

<footer class="system-footer">
<span>PicGen Console v0.1.57</span>
<span>PicGen Console v0.1.58</span>
<span>本地代理保存结果到 <strong>data/outputs</strong></span>
</footer>
</div>
Expand Down Expand Up @@ -1356,6 +1356,6 @@ <h2 id="previewModalTitle">图片预览</h2>
<span id="toastMessageText"></span>
</div>

<script src="app.js?v=0.1.57" type="module"></script>
<script src="app.js?v=0.1.58" type="module"></script>
</body>
</html>
87 changes: 85 additions & 2 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,9 @@ def test_itinerary_map_render_saves_integrated_ai_artwork_with_geometry_control(
assert fake.run_responses.await_args.args[1] == "sk-test"
assert upstream_payload["model"] == "gpt-5.6-sol"
assert upstream_payload["reasoning"] == {"effort": "xhigh"}
assert upstream_payload["parallel_tool_calls"] is False
assert "max_tool_calls" not in upstream_payload
assert upstream_payload["tool_choice"] == {"type": "image_generation"}
assert upstream_payload["tools"][0]["size"] == "1792x1792"
content = upstream_payload["input"][0]["content"]
assert content[0]["type"] == "input_text"
Expand Down Expand Up @@ -2522,9 +2525,77 @@ def test_responses_image_v4_legacy_model_is_normalized_and_saves_streamed_image(
assert "图像生成助手" in upstream_payload["instructions"]
assert upstream_payload["stream"] is True
assert upstream_payload["reasoning"] == {"effort": "xhigh"}
assert upstream_payload["parallel_tool_calls"] is False
assert "max_tool_calls" not in upstream_payload
assert upstream_payload["tool_choice"] == {"type": "image_generation"}
assert upstream_payload["tools"] == [{"type": "image_generation", "size": "1088x2240", "quality": "high"}]


def test_responses_image_caps_unexpected_extra_upstream_candidates(make_client, settings_factory):
settings = settings_factory(default_api_key="sk-test")
client, fake, resolved = make_client(settings=settings)
fake.run_responses.return_value = {
"data": [
{"b64_json": valid_png_b64(10 + index, 20 + index), "revised_prompt": f"candidate-{index}"}
for index in range(6)
],
"created": 1,
}

response = client.post(
"/api/responses-image",
json={
"api_key": "sk-test",
"endpoint_url": "https://sub.tidba.com/v1/responses",
"prompt": "只生成一张旅行海报",
"model": "gpt-5.6-sol",
"mode": "generate",
"sample_count": 1,
"size": "20x40",
},
)

assert response.status_code == 200
payload = response.json()
assert payload["candidate_count"] == 1
assert len(payload["images"]) == 1
assert payload["images"][0]["revised_prompt"] == "candidate-0"
assert len([path for path in resolved.outputs_dir.rglob("*") if path.is_file()]) == 1
upstream_payload = fake.run_responses.await_args.args[2]
assert upstream_payload["parallel_tool_calls"] is False
assert "max_tool_calls" not in upstream_payload
assert upstream_payload["tool_choice"] == {"type": "image_generation"}
assert "n" not in upstream_payload["tools"][0]
assert "只调用 image_generation 工具 1 次" in upstream_payload["input"][0]["content"][0]["text"]


def test_responses_image_does_not_retry_rejected_legacy_sample_count_parameter(
make_client, settings_factory
):
settings = settings_factory(default_api_key="sk-test")
client, fake, _ = make_client(settings=settings)
fake.run_responses.side_effect = APIError(
400,
'image_generation parameter "n" is not supported',
code="upstream_error",
)

response = client.post(
"/api/responses-image",
json={
"api_key": "sk-test",
"endpoint_url": "https://sub.tidba.com/v1/responses",
"prompt": "只生成一张旅行海报",
"model": "gpt-5.6-sol",
"sample_count": 3,
},
)

assert response.status_code == 400
assert response.json()["code"] == "upstream_error"
fake.run_responses.assert_awaited_once()


def test_responses_image_uploads_input_file_and_uses_file_id(make_client, settings_factory):
settings = settings_factory(default_api_key="sk-test")
client, fake, _ = make_client(settings=settings)
Expand Down Expand Up @@ -2567,7 +2638,13 @@ def test_responses_image_uploads_ordered_reference_images(make_client, settings_
settings = settings_factory(default_api_key="sk-test")
client, fake, _ = make_client(settings=settings)
fake.run_file_upload.side_effect = [{"id": "file_style"}, {"id": "file_material"}]
fake.run_responses.return_value = {"data": [{"b64_json": TINY_PNG_B64}], "created": 1}
fake.run_responses.return_value = {
"data": [
{"b64_json": valid_png_b64(20 + index, 40 + index), "revised_prompt": f"candidate-{index}"}
for index in range(4)
],
"created": 1,
}

response = client.post(
"/api/responses-image",
Expand Down Expand Up @@ -2599,15 +2676,21 @@ def test_responses_image_uploads_ordered_reference_images(make_client, settings_
payload = response.json()
assert payload["source_file_ids"] == ["file_style", "file_material"]
assert payload["source_image_names"] == ["style.png", "material.png"]
assert payload["candidate_count"] == 3
assert len(payload["images"]) == 3
assert fake.run_file_upload.await_count == 2

uploaded = [call.args[2]["filename"] for call in fake.run_file_upload.await_args_list]
assert uploaded == ["style.png", "material.png"]
upstream_payload = fake.run_responses.await_args.args[2]
assert upstream_payload["tools"][0]["n"] == 3
assert upstream_payload["parallel_tool_calls"] is False
assert "max_tool_calls" not in upstream_payload
assert upstream_payload["tool_choice"] == {"type": "image_generation"}
assert "n" not in upstream_payload["tools"][0]
content = upstream_payload["input"][0]["content"]
assert content[0]["type"] == "input_text"
assert content[0]["text"].startswith("把素材做成模板风格")
assert "只调用 image_generation 工具 3 次" in content[0]["text"]
assert "硬性目的地限制" in content[0]["text"]
assert content[1:] == [
{"type": "input_image", "file_id": "file_style"},
Expand Down
4 changes: 3 additions & 1 deletion tests/test_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,18 +232,20 @@ def test_placeholder_candidates_are_dropped_and_index_preserved(tmp_path: Path)
"data": [
{"revised_prompt": "moderated away"}, # no image at all
{"b64_json": TINY_PNG_B64},
{"b64_json": TINY_PNG_B64, "revised_prompt": "extra valid candidate"},
],
}
payload = prepare_image_payload(
upstream,
data_dir=tmp_path,
outputs_dir=tmp_path / "outputs",
user_agent="UA",
save_context={"mode": "generate"},
save_context={"mode": "generate", "sample_count": 1},
fetch_remote=None,
)
assert payload["candidate_count"] == 1
assert payload["images"][0]["candidate_index"] == 1
assert len([path for path in (tmp_path / "outputs").rglob("*") if path.is_file()]) == 1


def test_all_placeholder_candidates_yield_zero_count(tmp_path: Path) -> None:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_static_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def test_legacy_responses_model_storage_is_migrated_once() -> None:
settings_js = (ROOT_DIR / "static" / "responses-settings.mjs").read_text(encoding="utf-8")

assert 'const DEPRECATED_RESPONSES_MODELS = new Set(["gpt-5.4"])' in app_js
assert 'from "./responses-settings.mjs?v=0.1.57"' in app_js
assert 'from "./responses-settings.mjs?v=0.1.58"' in app_js
assert 'const LEGACY_DEFAULT_RESPONSES_MODEL = "gpt-5.5"' in settings_js
assert "const RESPONSES_MODEL_STORAGE_VERSION = 4" in settings_js
assert "function migrateStoredResponsesSettings" in settings_js
Expand All @@ -38,7 +38,7 @@ def test_logo_overlay_uses_uploaded_asset_without_ai_guidance() -> None:
assert 'const COMPANY_LOGO_URL = "6renyou.png"' in app_js
assert "composeLogoOverlayForCandidates" in app_js
assert "createOfficialLogoCanvas" in app_js
assert 'from "./logo-placement.mjs?v=0.1.57"' in app_js
assert 'from "./logo-placement.mjs?v=0.1.58"' in app_js
assert "chooseLogoPlacement" in app_js
assert "calculateLogoPlacementScore" in app_js
assert "expandLogoSafetyRegion" in placement_js
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading