fix: verify and preview generated test images
This commit is contained in:
@@ -1660,6 +1660,33 @@ textarea:focus-visible,
|
|||||||
color: var(--warning) !important;
|
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 {
|
.model-routing {
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
|
|||||||
|
|
||||||
type ModelPoolPanelProps = {
|
type ModelPoolPanelProps = {
|
||||||
draft: Record<string, unknown>;
|
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;
|
busy: string | null;
|
||||||
onChange: (key: string, value: unknown) => void;
|
onChange: (key: string, value: unknown) => void;
|
||||||
onTest: (modelId: string) => void;
|
onTest: (modelId: string) => void;
|
||||||
@@ -768,7 +768,7 @@ type ModelGroupProps = {
|
|||||||
description: string;
|
description: string;
|
||||||
category: ModelCategory;
|
category: ModelCategory;
|
||||||
models: ModelPoolItem[];
|
models: ModelPoolItem[];
|
||||||
tests: Record<string, { ok: boolean; message: string }>;
|
tests: Record<string, { ok: boolean; message: string; details?: Record<string, unknown> }>;
|
||||||
busy: string | null;
|
busy: string | null;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (model: ModelPoolItem) => void;
|
onEdit: (model: ModelPoolItem) => void;
|
||||||
@@ -800,6 +800,9 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd,
|
|||||||
const result = tests[target];
|
const result = tests[target];
|
||||||
const generationTarget = `generation:${model.id}`;
|
const generationTarget = `generation:${model.id}`;
|
||||||
const generationResult = tests[generationTarget];
|
const generationResult = tests[generationTarget];
|
||||||
|
const generationImageUrl = typeof generationResult?.details?.result_url === "string"
|
||||||
|
? generationResult.details.result_url
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<article className={`model-row ${!model.enabled ? "disabled" : ""}`} key={model.id}>
|
<article className={`model-row ${!model.enabled ? "disabled" : ""}`} key={model.id}>
|
||||||
<div className={`model-state ${result?.ok ? "ok" : result ? "failed" : "untested"}`}>
|
<div className={`model-state ${result?.ok ? "ok" : result ? "failed" : "untested"}`}>
|
||||||
@@ -820,7 +823,11 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd,
|
|||||||
</button>
|
</button>
|
||||||
{model.capabilities.includes("image_generation") ? (
|
{model.capabilities.includes("image_generation") ? (
|
||||||
<button className="button button-secondary compact" type="button" onClick={() => onTrial(model.id)} disabled={Boolean(busy) || !model.enabled} title="会消耗一次模型调用额度">
|
<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>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -832,7 +839,19 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd,
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{result ? <p className={`model-test-message ${result.ok ? "ok" : "failed"}`}>{result.message}</p> : <p className="model-test-message">尚未验证模型 ID。</p>}
|
{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>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export type SettingsResponse = {
|
|||||||
categories: SettingCategory[];
|
categories: SettingCategory[];
|
||||||
values: Record<string, unknown>;
|
values: Record<string, unknown>;
|
||||||
configured: Record<string, boolean>;
|
configured: Record<string, boolean>;
|
||||||
tests: Record<string, { ok: boolean; message: string }>;
|
tests: Record<string, { ok: boolean; message: string; details?: Record<string, unknown> }>;
|
||||||
readiness: SettingsReadiness;
|
readiness: SettingsReadiness;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@
|
|||||||
每个模型提供两种测试:
|
每个模型提供两种测试:
|
||||||
|
|
||||||
- “验证连接”只检查地址、API Key、模型列表、协议和路径,不生图、不扣费。部分聚合平台不会在 `/v1/models` 中公开媒体模型 ID,此时不能据此判定模型不可用。
|
- “验证连接”只检查地址、API Key、模型列表、协议和路径,不生图、不扣费。部分聚合平台不会在 `/v1/models` 中公开媒体模型 ID,此时不能据此判定模型不可用。
|
||||||
- “试生成”会在用户确认后真实生成一张低成本测试图并消耗一次调用额度。其成功结果是生图模型端到端可用性的最终依据,并会自动将该模型标记为已通过测试。
|
- “试生成”会在用户确认后真实生成一张低成本测试图并消耗一次调用额度。只有结果文件可以实际下载、通过图片格式与有效字节校验后才算成功,设置页会显示图片缩略图;成功结果会自动将该模型标记为已通过测试。
|
||||||
|
|
||||||
总调度模型必须由用户从大语言模型池中指定。空间理解和生图各自支持两种方式:
|
总调度模型必须由用户从大语言模型池中指定。空间理解和生图各自支持两种方式:
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import base64
|
import base64
|
||||||
|
import binascii
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -353,6 +354,7 @@ class EncryptedSettingsStore:
|
|||||||
document["tests"][result.target] = {
|
document["tests"][result.target] = {
|
||||||
"ok": result.ok,
|
"ok": result.ok,
|
||||||
"message": result.message,
|
"message": result.message,
|
||||||
|
"details": result.details,
|
||||||
"fingerprint": self.test_fingerprint(result.target, values or self.merged_values()),
|
"fingerprint": self.test_fingerprint(result.target, values or self.merged_values()),
|
||||||
}
|
}
|
||||||
self.save_document(document)
|
self.save_document(document)
|
||||||
@@ -486,15 +488,19 @@ class EncryptedSettingsStore:
|
|||||||
if mode == "manual":
|
if mode == "manual":
|
||||||
selected_id = str(values.get(selected_key, ""))
|
selected_id = str(values.get(selected_key, ""))
|
||||||
selected = models.get(selected_id)
|
selected = models.get(selected_id)
|
||||||
|
test_target = f"generation:{selected_id}" if capability == "image_generation" else f"model:{selected_id}"
|
||||||
if not selected_id:
|
if not selected_id:
|
||||||
model_missing.append(f"手动选择{label}模型")
|
model_missing.append(f"手动选择{label}模型")
|
||||||
elif not selected or capability not in selected.get("capabilities", []):
|
elif not selected or capability not in selected.get("capabilities", []):
|
||||||
model_missing.append(f"{label}模型已删除、已停用或能力不匹配")
|
model_missing.append(f"{label}模型已删除、已停用或能力不匹配")
|
||||||
elif not tests.get(f"model:{selected_id}", {}).get("ok"):
|
elif not tests.get(test_target, {}).get("ok"):
|
||||||
model_untested.append(f"{label}模型“{selected.get('name')}”尚未通过测试")
|
requirement = "真实试生成" if capability == "image_generation" else "连接测试"
|
||||||
|
model_untested.append(f"{label}模型“{selected.get('name')}”尚未通过{requirement}")
|
||||||
elif mode == "auto":
|
elif mode == "auto":
|
||||||
if not any(tests.get(f"model:{item['id']}", {}).get("ok") for item in eligible):
|
prefix = "generation" if capability == "image_generation" else "model"
|
||||||
model_untested.append(f"{label}自动路由没有已测试的候选模型")
|
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:
|
else:
|
||||||
model_missing.append(f"{label}路由方式无效")
|
model_missing.append(f"{label}路由方式无效")
|
||||||
|
|
||||||
@@ -871,13 +877,22 @@ async def test_image_generation(values: dict[str, Any], model_id: str) -> Runtim
|
|||||||
ok=False,
|
ok=False,
|
||||||
message="请求已结束,但响应中没有找到图片地址或图片数据。",
|
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":
|
if output[0] == "url":
|
||||||
details["result_url"] = output[1]
|
details["result_url"] = output[1]
|
||||||
return RuntimeSettingsTestResult(
|
return RuntimeSettingsTestResult(
|
||||||
target=target,
|
target=target,
|
||||||
ok=True,
|
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,
|
details=details,
|
||||||
)
|
)
|
||||||
except (httpx.HTTPError, RuntimeError, ValueError) as exc:
|
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"):
|
if row.get("url"):
|
||||||
return "url", str(row["url"])
|
return "url", str(row["url"])
|
||||||
if row.get("b64_json"):
|
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 []:
|
for candidate in payload.get("candidates", []) if isinstance(payload.get("candidates"), list) else []:
|
||||||
parts = candidate.get("content", {}).get("parts", []) if isinstance(candidate, dict) else []
|
parts = candidate.get("content", {}).get("parts", []) if isinstance(candidate, dict) else []
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if isinstance(part, dict) and (part.get("inlineData", {}).get("data") or part.get("inline_data", {}).get("data")):
|
if not isinstance(part, dict):
|
||||||
return "inline", ""
|
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
|
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:
|
async def _test_gpu(values: dict[str, Any]) -> RuntimeSettingsTestResult:
|
||||||
mode = values.get("gpu_mode", "disabled")
|
mode = values.get("gpu_mode", "disabled")
|
||||||
if mode == "disabled":
|
if mode == "disabled":
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.integrations.openai_compatible import OpenAICompatibleGateway
|
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:
|
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 [item.target for item in store.recorded] == ["generation:image", "model:image"]
|
||||||
assert store.recorded[1].ok is True
|
assert store.recorded[1].ok is True
|
||||||
assert "真实生图端到端验证" in store.recorded[1].message
|
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
|
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"))
|
store.record_test(RuntimeSettingsTestResult(target=target, ok=True, message="ok"))
|
||||||
|
|
||||||
assert store.public_response().readiness.ready is True
|
assert store.public_response().readiness.ready is True
|
||||||
|
|||||||
Reference in New Issue
Block a user