feat(ai): enforce direct image edit contract
This commit is contained in:
@@ -136,12 +136,16 @@ def validate_direct_generation_config(
|
||||
errors = []
|
||||
for category, label in required:
|
||||
try:
|
||||
_role_model(
|
||||
model = _role_model(
|
||||
category,
|
||||
ai_cfg.get("default_%s_model" % category),
|
||||
models_path,
|
||||
models=models,
|
||||
)
|
||||
if category == "image":
|
||||
compatibility_error = appconfig.image_model_config_error(model)
|
||||
if compatibility_error:
|
||||
raise AIError(compatibility_error)
|
||||
except Exception as exc:
|
||||
errors.append("%s模型%s" % (label, str(exc)))
|
||||
if errors:
|
||||
@@ -345,45 +349,28 @@ def gen_cover(
|
||||
quality = _jpg_quality(jpg_quality if jpg_quality is not None else ai_cfg.get("jpg_quality", 90))
|
||||
attempts = _attempt_count(ai_cfg, retry)
|
||||
|
||||
api_type = model.get("api_type", "auto")
|
||||
_notify_step(on_step, "cover_build_request")
|
||||
if api_type == "images_edits":
|
||||
body, content_type = _image_edit_body(model, cover_prompt, old_cover_path, resolution)
|
||||
_notify_step(on_step, "cover_request")
|
||||
data = _call_with_retry(
|
||||
compatibility_error = appconfig.image_model_config_error(model)
|
||||
if compatibility_error:
|
||||
raise AIError(compatibility_error)
|
||||
body, content_type = _image_edit_body(model, cover_prompt, [old_cover_path], resolution)
|
||||
_notify_step(on_step, "cover_request")
|
||||
data = _call_with_retry(
|
||||
model,
|
||||
body,
|
||||
cfg,
|
||||
attempts,
|
||||
request_kind="multipart",
|
||||
content_type=content_type,
|
||||
on_retry=lambda attempt, total_attempts, exc: _notify_retry(
|
||||
on_step,
|
||||
"cover_request",
|
||||
attempt,
|
||||
total_attempts,
|
||||
exc,
|
||||
model,
|
||||
body,
|
||||
cfg,
|
||||
attempts,
|
||||
request_kind="multipart",
|
||||
content_type=content_type,
|
||||
on_retry=lambda attempt, total_attempts, exc: _notify_retry(
|
||||
on_step,
|
||||
"cover_request",
|
||||
attempt,
|
||||
total_attempts,
|
||||
exc,
|
||||
model,
|
||||
),
|
||||
)
|
||||
else:
|
||||
payload = _image_chat_payload(model, cover_prompt, old_cover_path, resolution)
|
||||
_notify_step(on_step, "cover_request")
|
||||
data = _call_with_retry(
|
||||
model,
|
||||
payload,
|
||||
cfg,
|
||||
attempts,
|
||||
request_kind="json",
|
||||
on_retry=lambda attempt, total_attempts, exc: _notify_retry(
|
||||
on_step,
|
||||
"cover_request",
|
||||
attempt,
|
||||
total_attempts,
|
||||
exc,
|
||||
model,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
_notify_step(on_step, "cover_parse_response")
|
||||
image_bytes = _extract_image_bytes(data, model, cfg)
|
||||
@@ -1624,7 +1611,7 @@ def _debug_cmhub_image_url_enabled():
|
||||
|
||||
|
||||
def _extract_cmhub_image_url(data, base_url):
|
||||
candidate = _find_image_ref(data)
|
||||
candidate = _find_cmhub_image_ref(data)
|
||||
if not candidate:
|
||||
return ""
|
||||
return _normalize_cmhub_image_url(candidate, base_url)
|
||||
@@ -2554,32 +2541,34 @@ def _chat_payload(model, messages):
|
||||
return payload
|
||||
|
||||
|
||||
def _image_chat_payload(model, cover_prompt, old_cover_path, resolution):
|
||||
prompt = "%s\n\n目标分辨率:%s。" % (str(cover_prompt or "").strip(), resolution)
|
||||
content = [
|
||||
{"type": "text", "text": prompt.strip()},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": _image_data_url(old_cover_path)},
|
||||
},
|
||||
]
|
||||
return _chat_payload(model, [{"role": "user", "content": content}])
|
||||
|
||||
|
||||
def _image_edit_body(model, cover_prompt, old_cover_path, resolution):
|
||||
fields = {
|
||||
def _image_edit_body(model, cover_prompt, image_paths, resolution):
|
||||
if isinstance(image_paths, (str, bytes, os.PathLike)):
|
||||
image_paths = [image_paths]
|
||||
image_paths = list(image_paths or [])
|
||||
if not image_paths:
|
||||
raise AIError("图片编辑请求至少需要一张本地参考图")
|
||||
fields = copy.deepcopy(model.get("extra_body", {}))
|
||||
fields.update({
|
||||
"model": model["model"],
|
||||
"prompt": str(cover_prompt or ""),
|
||||
"size": _resolution_size_text(resolution),
|
||||
}
|
||||
fields.update(copy.deepcopy(model.get("extra_body", {})))
|
||||
files = {
|
||||
"image": (
|
||||
os.path.basename(old_cover_path),
|
||||
open(old_cover_path, "rb").read(),
|
||||
mimetypes.guess_type(old_cover_path)[0] or "application/octet-stream",
|
||||
"n": "1",
|
||||
})
|
||||
files = []
|
||||
for image_path in image_paths:
|
||||
path = os.fspath(image_path)
|
||||
with open(path, "rb") as fh:
|
||||
data = fh.read()
|
||||
files.append(
|
||||
(
|
||||
"image[]",
|
||||
(
|
||||
os.path.basename(path),
|
||||
data,
|
||||
mimetypes.guess_type(path)[0] or "application/octet-stream",
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
return _multipart_body(fields, files)
|
||||
|
||||
|
||||
@@ -2595,7 +2584,8 @@ def _multipart_body(fields, files):
|
||||
b"\r\n",
|
||||
]
|
||||
)
|
||||
for name, file_info in files.items():
|
||||
file_items = files.items() if isinstance(files, dict) else files
|
||||
for name, file_info in file_items:
|
||||
filename, data, content_type = file_info
|
||||
chunks.extend(
|
||||
[
|
||||
@@ -2656,15 +2646,38 @@ def _content_text(content):
|
||||
def _extract_image_bytes(data, model, config):
|
||||
image_ref = _find_image_ref(data)
|
||||
if not image_ref:
|
||||
raise AIError("AI 返回中没有图片数据")
|
||||
raise AIError("AI 图片响应不符合 OpenAI 图片编辑接口")
|
||||
if image_ref.startswith("data:"):
|
||||
return _decode_data_url(image_ref)
|
||||
if _looks_base64(image_ref):
|
||||
return base64.b64decode(image_ref)
|
||||
parts = urllib.parse.urlsplit(image_ref)
|
||||
if parts.scheme not in {"http", "https"} or not parts.netloc:
|
||||
raise AIError("AI 图片地址只允许 http/https")
|
||||
return _download_image(image_ref, model, config)
|
||||
|
||||
|
||||
def _find_image_ref(value):
|
||||
"""Read only the fixed OpenAI Images API response fields for direct calls."""
|
||||
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
data = value.get("data")
|
||||
if not isinstance(data, list):
|
||||
return None
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for key in ("b64_json", "url"):
|
||||
candidate = item.get(key)
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
return candidate.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _find_cmhub_image_ref(value):
|
||||
"""Read the default gateway's documented and legacy image response shapes."""
|
||||
|
||||
if isinstance(value, dict):
|
||||
for key in ("b64_json", "base64", "image_base64", "image", "url"):
|
||||
candidate = value.get(key)
|
||||
@@ -2678,7 +2691,7 @@ def _find_image_ref(value):
|
||||
if isinstance(candidate, str):
|
||||
return candidate
|
||||
if isinstance(image_url, list):
|
||||
candidate = _find_image_ref_from_list(image_url)
|
||||
candidate = _find_cmhub_image_ref_from_list(image_url)
|
||||
if candidate:
|
||||
return candidate
|
||||
for key in ("image_urls", "urls"):
|
||||
@@ -2686,28 +2699,28 @@ def _find_image_ref(value):
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
return candidate.strip()
|
||||
if isinstance(candidate, list):
|
||||
found = _find_image_ref_from_list(candidate)
|
||||
found = _find_cmhub_image_ref_from_list(candidate)
|
||||
if found:
|
||||
return found
|
||||
for key in ("result", "data", "choices", "output", "content", "images", "files"):
|
||||
candidate = _find_image_ref(value.get(key))
|
||||
candidate = _find_cmhub_image_ref(value.get(key))
|
||||
if candidate:
|
||||
return candidate
|
||||
message = value.get("message")
|
||||
if message is not None:
|
||||
candidate = _find_image_ref(message)
|
||||
candidate = _find_cmhub_image_ref(message)
|
||||
if candidate:
|
||||
return candidate
|
||||
elif isinstance(value, list):
|
||||
return _find_image_ref_from_list(value)
|
||||
return _find_cmhub_image_ref_from_list(value)
|
||||
return None
|
||||
|
||||
|
||||
def _find_image_ref_from_list(values):
|
||||
def _find_cmhub_image_ref_from_list(values):
|
||||
for item in values:
|
||||
if isinstance(item, str) and item.strip():
|
||||
return item.strip()
|
||||
candidate = _find_image_ref(item)
|
||||
candidate = _find_cmhub_image_ref(item)
|
||||
if candidate:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
+52
-2
@@ -142,10 +142,10 @@ DEFAULT_AI_MODELS_CONFIG = {
|
||||
"name": "Nano Banana 2",
|
||||
"category": "image",
|
||||
"enabled": True,
|
||||
"url": "https://api.vectorengine.ai/v1/chat/completions",
|
||||
"url": "https://api.vectorengine.ai/v1",
|
||||
"model": "gemini-3.1-flash-image-preview",
|
||||
"api_key": "",
|
||||
"api_type": "auto",
|
||||
"api_type": "images_edits",
|
||||
"connect_timeout_seconds": 30,
|
||||
"timeout_seconds": 0,
|
||||
"extra_body": {},
|
||||
@@ -1018,6 +1018,56 @@ def get_model(name, path=AI_MODELS_PATH) -> dict:
|
||||
return copy.deepcopy(models[_model_index(models, name)])
|
||||
|
||||
|
||||
def is_image_edit_model(model) -> bool:
|
||||
"""Return whether a model satisfies the direct image-edit contract."""
|
||||
|
||||
return (
|
||||
isinstance(model, dict)
|
||||
and model.get("category") == "image"
|
||||
and model.get("api_type") == "images_edits"
|
||||
)
|
||||
|
||||
|
||||
def image_model_config_error(model) -> str:
|
||||
"""Return a Chinese actionable error for a direct image model, if any."""
|
||||
|
||||
if not isinstance(model, dict) or model.get("category") != "image":
|
||||
return "当前模型不是图片模型"
|
||||
if model.get("api_type") != "images_edits":
|
||||
return "图片模型仅支持 OpenAI 图片编辑接口,请在设置中选择该接口类型"
|
||||
missing = [
|
||||
field
|
||||
for field in ("url", "model", "api_key")
|
||||
if not str(model.get(field, "") or "").strip()
|
||||
]
|
||||
if missing:
|
||||
return "图片模型缺少必要配置:" + "、".join(missing)
|
||||
url = str(model.get("url") or "").strip()
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
if parts.scheme not in {"http", "https"} or not parts.netloc:
|
||||
return "图片模型网址必须使用 http 或 https"
|
||||
try:
|
||||
connect_timeout = int(model.get("connect_timeout_seconds", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
connect_timeout = 0
|
||||
if connect_timeout <= 0:
|
||||
return "图片模型连接超时必须大于 0 秒"
|
||||
return ""
|
||||
|
||||
|
||||
def check_image_model_config(name, path=AI_MODELS_PATH) -> dict:
|
||||
"""Validate one image model locally without making a billable request."""
|
||||
|
||||
try:
|
||||
model = get_model(name, path=path)
|
||||
error = image_model_config_error(model)
|
||||
if error:
|
||||
return {"ok": False, "check_only": True, "error": error}
|
||||
return {"ok": True, "check_only": True}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "check_only": True, "error": str(exc)}
|
||||
|
||||
|
||||
|
||||
def model_request_url(model) -> str:
|
||||
"""Return the HTTP endpoint used for a configured AI model."""
|
||||
|
||||
@@ -36,6 +36,7 @@ class SettingsTab(QWidget):
|
||||
BACKEND_ITEMS = [("默认网关", "cmhub"), ("自定义网关", "direct")]
|
||||
CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")]
|
||||
API_TYPE_ITEMS = [("chat", "chat"), ("images_edits", "images_edits"), ("auto", "auto")]
|
||||
IMAGE_API_TYPE_ITEMS = [("OpenAI 图片编辑接口", "images_edits")]
|
||||
RESOLUTION_ITEMS = ["512", "1k", "2k", "4k"]
|
||||
|
||||
def __init__(
|
||||
@@ -431,6 +432,7 @@ class SettingsTab(QWidget):
|
||||
self.delete_model_button.clicked.connect(self.delete_model)
|
||||
self.save_model_button.clicked.connect(self.save_model)
|
||||
self.test_connection_button.clicked.connect(self.test_connection)
|
||||
self.category_combo.currentIndexChanged.connect(self._on_model_category_changed)
|
||||
self.gateway_default_button.toggled.connect(self._on_gateway_source_toggled)
|
||||
self.gateway_custom_button.toggled.connect(self._on_gateway_source_toggled)
|
||||
self.cmhub_refresh_button.clicked.connect(self.refresh_cmhub_models)
|
||||
@@ -667,6 +669,8 @@ class SettingsTab(QWidget):
|
||||
label = f"{model['name']} · {self._category_label(model['category'])}"
|
||||
if not model.get("enabled", True):
|
||||
label += " · 已停用"
|
||||
if model.get("category") == "image" and not appconfig.is_image_edit_model(model):
|
||||
label += " · 当前图片模型不支持 OpenAI 图片编辑接口"
|
||||
self.model_combo.addItem(label, model["name"])
|
||||
index = self.model_combo.findData(current)
|
||||
self.model_combo.setCurrentIndex(index if index >= 0 else (0 if self.models else -1))
|
||||
@@ -765,6 +769,7 @@ class SettingsTab(QWidget):
|
||||
ai_models_path=self.ai_models_path,
|
||||
db_path=_database_path(config=self.config),
|
||||
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
|
||||
check_image_config=model.get("category") == "image",
|
||||
)
|
||||
worker.finished.connect(self._on_test_finished)
|
||||
worker.failed.connect(self._on_test_failed)
|
||||
@@ -773,8 +778,12 @@ class SettingsTab(QWidget):
|
||||
self.test_worker = worker
|
||||
self.test_thread = thread
|
||||
self._set_test_running(True)
|
||||
self.test_result_label.setText("正在测试连接...")
|
||||
self._set_status(f"正在测试 AI 模型连接:{model['name']}")
|
||||
if model.get("category") == "image":
|
||||
self.test_result_label.setText("正在检查图片配置...")
|
||||
self._set_status(f"正在检查图片模型配置:{model['name']}")
|
||||
else:
|
||||
self.test_result_label.setText("正在测试连接...")
|
||||
self._set_status(f"正在测试 AI 模型连接:{model['name']}")
|
||||
thread.start()
|
||||
|
||||
def save_app_settings(self, checked=False):
|
||||
@@ -1010,7 +1019,10 @@ class SettingsTab(QWidget):
|
||||
combo.clear()
|
||||
for model in self.models:
|
||||
if model.get("category") == category and model.get("enabled", True):
|
||||
combo.addItem(model.get("name", ""), model.get("name", ""))
|
||||
label = model.get("name", "")
|
||||
if category == "image" and not appconfig.is_image_edit_model(model):
|
||||
label += " · 当前图片模型不支持 OpenAI 图片编辑接口"
|
||||
combo.addItem(label, model.get("name", ""))
|
||||
if combo.count() == 0:
|
||||
combo.addItem("无可用模型", None)
|
||||
index = combo.findData(selected)
|
||||
@@ -1058,6 +1070,32 @@ class SettingsTab(QWidget):
|
||||
"extra_body": dict(extra_body),
|
||||
}
|
||||
|
||||
def _on_model_category_changed(self, index=None):
|
||||
category = self.category_combo.currentData() or "text"
|
||||
current_type = self.api_type_combo.currentData()
|
||||
if category == "image":
|
||||
self._set_api_type_options(category, selected="images_edits")
|
||||
else:
|
||||
self._set_api_type_options(category, selected=current_type or "chat")
|
||||
self._update_button_state()
|
||||
|
||||
def _set_api_type_options(self, category, selected=None):
|
||||
"""Render category-specific API choices without mutating legacy models."""
|
||||
|
||||
self.api_type_combo.blockSignals(True)
|
||||
self.api_type_combo.clear()
|
||||
if category == "image":
|
||||
# Keep a stored legacy choice visible so users can correct it explicitly.
|
||||
if selected and selected != "images_edits":
|
||||
self.api_type_combo.addItem("当前不支持(%s)" % selected, selected)
|
||||
items = self.IMAGE_API_TYPE_ITEMS
|
||||
else:
|
||||
items = self.API_TYPE_ITEMS
|
||||
for label, value in items:
|
||||
self.api_type_combo.addItem(label, value)
|
||||
self._set_combo_by_data(self.api_type_combo, selected)
|
||||
self.api_type_combo.blockSignals(False)
|
||||
|
||||
def _populate_form(self, model):
|
||||
widgets = [
|
||||
self.enabled_checkbox,
|
||||
@@ -1075,7 +1113,7 @@ class SettingsTab(QWidget):
|
||||
self.enabled_checkbox.setChecked(False)
|
||||
self.name_edit.clear()
|
||||
self.category_combo.setCurrentIndex(0)
|
||||
self.api_type_combo.setCurrentIndex(0)
|
||||
self._set_api_type_options("text", selected="chat")
|
||||
self.model_id_edit.clear()
|
||||
self.url_edit.clear()
|
||||
self.api_key_edit.clear()
|
||||
@@ -1084,7 +1122,10 @@ class SettingsTab(QWidget):
|
||||
self.enabled_checkbox.setChecked(bool(model.get("enabled", True)))
|
||||
self.name_edit.setText(model.get("name", ""))
|
||||
self._set_combo_by_data(self.category_combo, model.get("category", "text"))
|
||||
self._set_combo_by_data(self.api_type_combo, model.get("api_type", "auto"))
|
||||
self._set_api_type_options(
|
||||
model.get("category", "text"),
|
||||
selected=model.get("api_type", "auto"),
|
||||
)
|
||||
self.model_id_edit.setText(model.get("model", ""))
|
||||
self.url_edit.setText(model.get("url", ""))
|
||||
self.api_key_edit.setText(model.get("api_key", ""))
|
||||
@@ -1118,6 +1159,13 @@ class SettingsTab(QWidget):
|
||||
has_model and not testing and self._can_delete_model(self._current_model())
|
||||
)
|
||||
self.test_connection_button.setEnabled(has_model and not testing)
|
||||
if not testing:
|
||||
model = self._current_model()
|
||||
self.test_connection_button.setText(
|
||||
"检查图片配置"
|
||||
if model is not None and model.get("category") == "image"
|
||||
else "测试连接"
|
||||
)
|
||||
|
||||
def _set_test_running(self, running):
|
||||
self._update_button_state()
|
||||
@@ -1132,7 +1180,11 @@ class SettingsTab(QWidget):
|
||||
self._set_test_running(False)
|
||||
|
||||
def _on_test_finished(self, payload):
|
||||
if payload.get("ok"):
|
||||
if payload.get("check_only") and payload.get("ok"):
|
||||
message = f"图片配置检查通过:{payload.get('name')}"
|
||||
elif payload.get("check_only"):
|
||||
message = "图片配置检查失败:%s" % (payload.get("error") or "配置不完整")
|
||||
elif payload.get("ok"):
|
||||
status = payload.get("status")
|
||||
suffix = f"(HTTP {status})" if status else ""
|
||||
message = f"测试连接成功:{payload.get('name')}{suffix}"
|
||||
|
||||
+21
-4
@@ -3261,24 +3261,41 @@ class CMHubSettingsWorker(BaseWorker):
|
||||
return _elapsed_ms(started)
|
||||
|
||||
class AIModelTestWorker(BaseWorker):
|
||||
"""Test one AI model connection without blocking the GUI thread."""
|
||||
"""Test text models or validate image-model configuration off the GUI thread."""
|
||||
|
||||
def __init__(self, model_name, ai_models_path=None, db_path=None, diagnostic_log_dir=None):
|
||||
def __init__(
|
||||
self,
|
||||
model_name,
|
||||
ai_models_path=None,
|
||||
db_path=None,
|
||||
diagnostic_log_dir=None,
|
||||
check_image_config=False,
|
||||
):
|
||||
super().__init__()
|
||||
self.model_name = model_name
|
||||
self.ai_models_path = ai_models_path or appconfig.AI_MODELS_PATH
|
||||
self.db_path = db_path
|
||||
self.diagnostic_log_dir = diagnostic_log_dir
|
||||
self.check_image_config = bool(check_image_config)
|
||||
self._run_id = None
|
||||
|
||||
def execute(self):
|
||||
self._run_id = self._create_run_log()
|
||||
started = time.monotonic()
|
||||
self._log_run_event(
|
||||
f"step=test_connection result=start detail=AI模型 {self.model_name}"
|
||||
"step={step} result=start detail=AI模型 {name}".format(
|
||||
step="check_image_config" if self.check_image_config else "test_connection",
|
||||
name=self.model_name,
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = appconfig.test_ai_model(self.model_name, path=self.ai_models_path)
|
||||
if self.check_image_config:
|
||||
result = appconfig.check_image_model_config(
|
||||
self.model_name,
|
||||
path=self.ai_models_path,
|
||||
)
|
||||
else:
|
||||
result = appconfig.test_ai_model(self.model_name, path=self.ai_models_path)
|
||||
except Exception as exc:
|
||||
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
|
||||
elapsed_ms = self._elapsed_ms(started)
|
||||
|
||||
Reference in New Issue
Block a user