From 0eb2a86f369013394255dbc5bd4daf7005c9af12 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 2 Aug 2026 00:05:13 +0800 Subject: [PATCH] fix: verify and preview generated test images --- apps/web/app/globals.css | 27 +++++++ apps/web/components/settings-center.tsx | 27 +++++-- apps/web/types/settings.ts | 2 +- docs/settings.md | 2 +- services/api/app/runtime_settings.py | 91 +++++++++++++++++++++--- services/api/tests/test_image_gateway.py | 61 +++++++++++++++- services/api/tests/test_settings.py | 2 +- 7 files changed, 195 insertions(+), 17 deletions(-) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 55e4d01..44cf21a 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1660,6 +1660,33 @@ textarea:focus-visible, color: var(--warning) !important; } +.model-generation-result { + grid-column: 2 / -1; + display: flex; + align-items: flex-start; + gap: 12px; +} + +.model-generation-result .model-test-message { + grid-column: auto; + flex: 1; +} + +.model-generation-result a { + display: block; + flex: 0 0 96px; +} + +.model-generation-result img { + display: block; + width: 96px; + height: 72px; + object-fit: cover; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-muted); +} + .model-routing { border-top: 1px solid var(--line); } diff --git a/apps/web/components/settings-center.tsx b/apps/web/components/settings-center.tsx index aecb5a8..e6736e4 100644 --- a/apps/web/components/settings-center.tsx +++ b/apps/web/components/settings-center.tsx @@ -452,7 +452,7 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen type ModelPoolPanelProps = { draft: Record; - tests: Record; + tests: Record }>; busy: string | null; onChange: (key: string, value: unknown) => void; onTest: (modelId: string) => void; @@ -768,7 +768,7 @@ type ModelGroupProps = { description: string; category: ModelCategory; models: ModelPoolItem[]; - tests: Record; + tests: Record }>; busy: string | null; onAdd: () => void; onEdit: (model: ModelPoolItem) => void; @@ -800,6 +800,9 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd, const result = tests[target]; const generationTarget = `generation:${model.id}`; const generationResult = tests[generationTarget]; + const generationImageUrl = typeof generationResult?.details?.result_url === "string" + ? generationResult.details.result_url + : null; return (
@@ -820,7 +823,11 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd, {model.capabilities.includes("image_generation") ? ( ) : null} @@ -832,7 +839,19 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd,
{result ?

{result.message}

:

尚未验证模型 ID。

} - {generationResult ?

{generationResult.message}

: null} + {generationResult ? ( +
+

{generationResult.message}

+ {generationResult.ok && generationImageUrl ? ( + + {/* eslint-disable-next-line @next/next/no-img-element -- runtime model URLs are not known to Next Image. */} + {`${model.name} + + ) : null} +
+ ) : model.capabilities.includes("image_generation") ? ( +

尚未完成真实生图验证;连接成功不代表已经获得有效图片。

+ ) : null}
); })} diff --git a/apps/web/types/settings.ts b/apps/web/types/settings.ts index db53a8c..031ae69 100644 --- a/apps/web/types/settings.ts +++ b/apps/web/types/settings.ts @@ -40,7 +40,7 @@ export type SettingsResponse = { categories: SettingCategory[]; values: Record; configured: Record; - tests: Record; + tests: Record }>; readiness: SettingsReadiness; }; diff --git a/docs/settings.md b/docs/settings.md index 6391e24..5d123fe 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -40,7 +40,7 @@ 每个模型提供两种测试: - “验证连接”只检查地址、API Key、模型列表、协议和路径,不生图、不扣费。部分聚合平台不会在 `/v1/models` 中公开媒体模型 ID,此时不能据此判定模型不可用。 -- “试生成”会在用户确认后真实生成一张低成本测试图并消耗一次调用额度。其成功结果是生图模型端到端可用性的最终依据,并会自动将该模型标记为已通过测试。 +- “试生成”会在用户确认后真实生成一张低成本测试图并消耗一次调用额度。只有结果文件可以实际下载、通过图片格式与有效字节校验后才算成功,设置页会显示图片缩略图;成功结果会自动将该模型标记为已通过测试。 总调度模型必须由用户从大语言模型池中指定。空间理解和生图各自支持两种方式: diff --git a/services/api/app/runtime_settings.py b/services/api/app/runtime_settings.py index 6d37a51..d580200 100644 --- a/services/api/app/runtime_settings.py +++ b/services/api/app/runtime_settings.py @@ -1,4 +1,5 @@ import base64 +import binascii import hashlib import json import os @@ -353,6 +354,7 @@ class EncryptedSettingsStore: document["tests"][result.target] = { "ok": result.ok, "message": result.message, + "details": result.details, "fingerprint": self.test_fingerprint(result.target, values or self.merged_values()), } self.save_document(document) @@ -486,15 +488,19 @@ class EncryptedSettingsStore: if mode == "manual": selected_id = str(values.get(selected_key, "")) selected = models.get(selected_id) + test_target = f"generation:{selected_id}" if capability == "image_generation" else f"model:{selected_id}" if not selected_id: model_missing.append(f"手动选择{label}模型") elif not selected or capability not in selected.get("capabilities", []): model_missing.append(f"{label}模型已删除、已停用或能力不匹配") - elif not tests.get(f"model:{selected_id}", {}).get("ok"): - model_untested.append(f"{label}模型“{selected.get('name')}”尚未通过测试") + elif not tests.get(test_target, {}).get("ok"): + requirement = "真实试生成" if capability == "image_generation" else "连接测试" + model_untested.append(f"{label}模型“{selected.get('name')}”尚未通过{requirement}") elif mode == "auto": - if not any(tests.get(f"model:{item['id']}", {}).get("ok") for item in eligible): - model_untested.append(f"{label}自动路由没有已测试的候选模型") + prefix = "generation" if capability == "image_generation" else "model" + if not any(tests.get(f"{prefix}:{item['id']}", {}).get("ok") for item in eligible): + requirement = "真实试生成" if capability == "image_generation" else "连接测试" + model_untested.append(f"{label}自动路由没有已通过{requirement}的候选模型") else: model_missing.append(f"{label}路由方式无效") @@ -871,13 +877,22 @@ async def test_image_generation(values: dict[str, Any], model_id: str) -> Runtim ok=False, message="请求已结束,但响应中没有找到图片地址或图片数据。", ) - details = {"model_id": item["model_id"], "output_kind": output[0]} + verification = await _verify_generated_image(output) + details = { + "model_id": item["model_id"], + "output_kind": output[0], + "verified_image": True, + **verification, + } if output[0] == "url": details["result_url"] = output[1] return RuntimeSettingsTestResult( target=target, ok=True, - message=f"模型“{item.get('name', item['model_id'])}”已完成一次真实生图,端到端配置可用。", + message=( + f"模型“{item.get('name', item['model_id'])}”已生成并成功下载校验一张" + f" {verification['image_format'].upper()} 图片({verification['byte_size'] // 1024} KB),端到端配置可用。" + ), details=details, ) except (httpx.HTTPError, RuntimeError, ValueError) as exc: @@ -893,15 +908,73 @@ def _find_generated_image(payload: dict[str, Any]) -> tuple[str, str] | None: if row.get("url"): return "url", str(row["url"]) if row.get("b64_json"): - return "inline", "" + return "inline", str(row["b64_json"]) for candidate in payload.get("candidates", []) if isinstance(payload.get("candidates"), list) else []: parts = candidate.get("content", {}).get("parts", []) if isinstance(candidate, dict) else [] for part in parts: - if isinstance(part, dict) and (part.get("inlineData", {}).get("data") or part.get("inline_data", {}).get("data")): - return "inline", "" + if not isinstance(part, dict): + continue + inline_data = part.get("inlineData") or part.get("inline_data") or {} + if isinstance(inline_data, dict) and inline_data.get("data"): + return "inline", str(inline_data["data"]) return None +async def _verify_generated_image(output: tuple[str, str]) -> dict[str, Any]: + kind, value = output + content_type = "" + if kind == "url": + async with httpx.AsyncClient(timeout=45, follow_redirects=True) as client: + response = await client.get(value) + response.raise_for_status() + image_bytes = response.content + content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower() + elif kind == "inline": + encoded = value.split(",", 1)[1] if value.startswith("data:") and "," in value else value + try: + image_bytes = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError("平台返回了无法解码的 Base64 图片数据") from exc + else: + raise ValueError(f"不支持的图片输出类型:{kind}") + + if len(image_bytes) < 1024: + raise ValueError(f"平台返回的图片文件过小({len(image_bytes)} 字节),不能视为有效生图") + if len(image_bytes) > 25 * 1024 * 1024: + raise ValueError("平台返回的测试图片超过 25 MB,已拒绝继续处理") + image_format = _detect_image_format(image_bytes) + if not image_format: + description = content_type or "未知内容类型" + raise ValueError(f"结果地址可以访问,但下载内容不是可识别的图片({description})") + if content_type and not content_type.startswith("image/") and content_type != "application/octet-stream": + raise ValueError(f"结果地址返回了非图片内容类型:{content_type}") + return { + "byte_size": len(image_bytes), + "content_type": content_type or f"image/{image_format}", + "image_format": image_format, + } + + +def _detect_image_format(content: bytes) -> str: + if content.startswith(b"\x89PNG\r\n\x1a\n"): + return "png" + if content.startswith(b"\xff\xd8\xff"): + return "jpeg" + if content.startswith((b"GIF87a", b"GIF89a")): + return "gif" + if content.startswith(b"RIFF") and content[8:12] == b"WEBP": + return "webp" + if content.startswith(b"BM"): + return "bmp" + if len(content) >= 12 and content[4:8] == b"ftyp": + brand = content[8:12] + if brand in {b"avif", b"avis"}: + return "avif" + if brand in {b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1"}: + return "heic" + return "" + + async def _test_gpu(values: dict[str, Any]) -> RuntimeSettingsTestResult: mode = values.get("gpu_mode", "disabled") if mode == "disabled": diff --git a/services/api/tests/test_image_gateway.py b/services/api/tests/test_image_gateway.py index affe182..08dbba2 100644 --- a/services/api/tests/test_image_gateway.py +++ b/services/api/tests/test_image_gateway.py @@ -1,7 +1,11 @@ import pytest from app.integrations.openai_compatible import OpenAICompatibleGateway -from app.runtime_settings import RuntimeSettingsTestResult, test_model_pool_item as run_model_pool_test +from app.runtime_settings import ( + RuntimeSettingsTestResult, + _verify_generated_image, + test_model_pool_item as run_model_pool_test, +) def image_values(profile: str, model_id: str) -> dict: @@ -175,3 +179,58 @@ async def test_generation_endpoint_records_definitive_model_result(monkeypatch) assert [item.target for item in store.recorded] == ["generation:image", "model:image"] assert store.recorded[1].ok is True assert "真实生图端到端验证" in store.recorded[1].message + + +@pytest.mark.asyncio +async def test_generated_image_url_must_download_as_real_image(monkeypatch) -> None: + png_bytes = b"\x89PNG\r\n\x1a\n" + b"x" * 2048 + + class FakeResponse: + content = png_bytes + headers = {"content-type": "image/png"} + + def raise_for_status(self) -> None: + return None + + class FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args) -> None: + return None + + async def get(self, url: str) -> FakeResponse: + assert url == "https://cdn.example.com/verified.png" + return FakeResponse() + + monkeypatch.setattr("app.runtime_settings.httpx.AsyncClient", lambda **kwargs: FakeClient()) + + details = await _verify_generated_image(("url", "https://cdn.example.com/verified.png")) + + assert details["image_format"] == "png" + assert details["byte_size"] == len(png_bytes) + + +@pytest.mark.asyncio +async def test_generated_image_url_rejects_html_placeholder(monkeypatch) -> None: + class FakeResponse: + content = b"not an image" + b"x" * 2048 + headers = {"content-type": "text/html"} + + def raise_for_status(self) -> None: + return None + + class FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args) -> None: + return None + + async def get(self, url: str) -> FakeResponse: + return FakeResponse() + + monkeypatch.setattr("app.runtime_settings.httpx.AsyncClient", lambda **kwargs: FakeClient()) + + with pytest.raises(ValueError, match="不是可识别的图片"): + await _verify_generated_image(("url", "https://cdn.example.com/not-image")) diff --git a/services/api/tests/test_settings.py b/services/api/tests/test_settings.py index 2b4b251..ab765b3 100644 --- a/services/api/tests/test_settings.py +++ b/services/api/tests/test_settings.py @@ -76,7 +76,7 @@ def test_readiness_requires_configuration_and_successful_tests(tmp_path: Path) - ) assert store.public_response().readiness.ready is False - for target in ("infrastructure", "storage", "model:planner", "model:designer"): + for target in ("infrastructure", "storage", "model:planner", "model:designer", "generation:designer"): store.record_test(RuntimeSettingsTestResult(target=target, ok=True, message="ok")) assert store.public_response().readiness.ready is True