fix: verify and preview generated test images
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -452,7 +452,7 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
|
||||
|
||||
type ModelPoolPanelProps = {
|
||||
draft: Record<string, unknown>;
|
||||
tests: Record<string, { ok: boolean; message: string }>;
|
||||
tests: Record<string, { ok: boolean; message: string; details?: Record<string, unknown> }>;
|
||||
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<string, { ok: boolean; message: string }>;
|
||||
tests: Record<string, { ok: boolean; message: string; details?: Record<string, unknown> }>;
|
||||
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 (
|
||||
<article className={`model-row ${!model.enabled ? "disabled" : ""}`} key={model.id}>
|
||||
<div className={`model-state ${result?.ok ? "ok" : result ? "failed" : "untested"}`}>
|
||||
@@ -820,7 +823,11 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd,
|
||||
</button>
|
||||
{model.capabilities.includes("image_generation") ? (
|
||||
<button className="button button-secondary compact" type="button" onClick={() => onTrial(model.id)} disabled={Boolean(busy) || !model.enabled} title="会消耗一次模型调用额度">
|
||||
{busy === generationTarget ? <ArrowClockwiseIcon className="spin" size={14} /> : <ImageIcon size={14} />}
|
||||
{busy === generationTarget
|
||||
? <ArrowClockwiseIcon className="spin" size={14} />
|
||||
: generationResult?.ok
|
||||
? <CheckCircleIcon size={14} weight="fill" />
|
||||
: <ImageIcon size={14} />}
|
||||
试生成
|
||||
</button>
|
||||
) : null}
|
||||
@@ -832,7 +839,19 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd,
|
||||
</button>
|
||||
</div>
|
||||
{result ? <p className={`model-test-message ${result.ok ? "ok" : "failed"}`}>{result.message}</p> : <p className="model-test-message">尚未验证模型 ID。</p>}
|
||||
{generationResult ? <p className={`model-test-message ${generationResult.ok ? "ok" : "failed"}`}>{generationResult.message}</p> : null}
|
||||
{generationResult ? (
|
||||
<div className="model-generation-result">
|
||||
<p className={`model-test-message ${generationResult.ok ? "ok" : "failed"}`}>{generationResult.message}</p>
|
||||
{generationResult.ok && generationImageUrl ? (
|
||||
<a href={generationImageUrl} target="_blank" rel="noreferrer" title="打开原始测试图片">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- runtime model URLs are not known to Next Image. */}
|
||||
<img src={generationImageUrl} alt={`${model.name} 真实试生成结果`} />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : model.capabilities.includes("image_generation") ? (
|
||||
<p className="model-test-message">尚未完成真实生图验证;连接成功不代表已经获得有效图片。</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -40,7 +40,7 @@ export type SettingsResponse = {
|
||||
categories: SettingCategory[];
|
||||
values: Record<string, unknown>;
|
||||
configured: Record<string, boolean>;
|
||||
tests: Record<string, { ok: boolean; message: string }>;
|
||||
tests: Record<string, { ok: boolean; message: string; details?: Record<string, unknown> }>;
|
||||
readiness: SettingsReadiness;
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
每个模型提供两种测试:
|
||||
|
||||
- “验证连接”只检查地址、API Key、模型列表、协议和路径,不生图、不扣费。部分聚合平台不会在 `/v1/models` 中公开媒体模型 ID,此时不能据此判定模型不可用。
|
||||
- “试生成”会在用户确认后真实生成一张低成本测试图并消耗一次调用额度。其成功结果是生图模型端到端可用性的最终依据,并会自动将该模型标记为已通过测试。
|
||||
- “试生成”会在用户确认后真实生成一张低成本测试图并消耗一次调用额度。只有结果文件可以实际下载、通过图片格式与有效字节校验后才算成功,设置页会显示图片缩略图;成功结果会自动将该模型标记为已通过测试。
|
||||
|
||||
总调度模型必须由用户从大语言模型池中指定。空间理解和生图各自支持两种方式:
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"<html><body>not an image</body></html>" + 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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user