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)
|
||||
|
||||
+4
-2
@@ -73,6 +73,8 @@ update_ai_model(name, **fields) -> None
|
||||
delete_ai_model(name) -> None # 至少各留一个 text+image;删到剩一禁用
|
||||
model_request_url(model) -> str # url 为 /v1 或 /api/v1 base 时按 api_type 补 /chat/completions 或 /images/edits
|
||||
test_ai_model(name) -> dict # 「测试连接」:用 key/url/model 发最小请求 -> {ok, status?, error?}
|
||||
check_image_model_config(name) -> dict # 图片模型本地校验,不发网络/计费请求 -> {ok, check_only, error?}
|
||||
is_image_edit_model(model) -> bool # category=image 且 api_type=images_edits
|
||||
get_model(name) -> dict # 返回模型定义,含 api_key(调用方不得写日志)
|
||||
```
|
||||
|
||||
@@ -330,7 +332,7 @@ fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> li
|
||||
|
||||
要点:
|
||||
|
||||
- `backend=direct`:设置页「自定义网关」的客户端直连兼容路径;标题用 `default_text_model`、封面用 `default_image_model`(`appconfig.get_model` 取定义,含 url/key/api_type)。②在创建 worker 前按本轮生成内容校验所需模型;运行中使用冻结的内存模型快照,不重读 `ai_models.json`。它不支持图片理解或商品套图新建任务。
|
||||
- `backend=direct`:设置页「自定义网关」的客户端直连兼容路径;标题用 `default_text_model`,保留 `chat` / `auto`;封面用 `default_image_model`,只接受 `category=image` 且 `api_type=images_edits`(`appconfig.get_model` 取定义,含 url/key/api_type)。封面固定 `POST /v1/images/edits`,multipart 中按顺序重复 `image[]`,首项是主体图,固定 `n=1`,只读取 `data[].b64_json` 或 `data[].url`,仅允许 data URL/base64/HTTP(S) 下载结果。②在创建 worker 前按本轮生成内容校验所需模型;运行中使用冻结的内存模型快照,不重读 `ai_models.json`。历史 `chat` / `auto` 图片模型仍可保存和查看,但不可提交图片生成。它不支持图片理解或商品套图新建任务。
|
||||
- `backend=cmhub`:设置页「默认网关」及普通产品默认路径;标题调用 `POST /api/v1/generate/title`;②批量封面生成调用 `POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`,模型字段使用 `ai.cmhub.title_alias/image_alias`,Key 来自 `data/config/cmhub.json`。商品套图中的「AI帮写」单独调用 `POST /api/v1/analyze/images`,只使用 `ai.cmhub.vision_alias`,不得回退或混用生文/生图别名;同一商品项目的原图在一条请求内联合理解,用户勾选状态不参与选图。`gen_cover()` 单独调用没有任务/DB 上下文,第一版保留旧同步 `POST /api/v1/generate/image` 兼容路径。
|
||||
- 标题提示词组装:`gen_title()` 的 direct 与 cmhub 路径共用标题 prompt 规则。若标题提示词包含 `{旧标题}`,生成前替换为该任务旧标题,不再自动追加旧标题块;若不包含 `{旧标题}`,保持旧行为自动追加“旧标题:...”块。两种情况都会追加“请只返回新标题,不要解释。”输出约束;其它 `{...}` 原样保留。
|
||||
- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供设置页动态下拉、商品套图 AI帮写和商品套图正式生成的付费前确认共用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。GUI 只在进程内按规整网关地址和别名短期缓存公开模型元数据,不缓存 API Key,也不写入配置、SQLite、日志或导出。AI帮写仅在当前 `vision_alias` 命中 `operation_type=vision`、`requires_image=true`、`pricing_status=priced` 且有唯一无条件 `points_cost` 时显示预计扣点;正式套图同样只在当前 `image_alias` 命中唯一无条件的图片生图价格时显示单张和按最终 `specs` 计算的总价。其它情况只提示实际以网关返回为准,预估值不参与扣减或成功判定。
|
||||
@@ -505,7 +507,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
|
||||
设置当前要点(T-501):
|
||||
|
||||
- `SettingsTab` 使用居中内容区 + 适度左右留白布局,当前留白已从 T-506 初始实现缩短到约 40%;实现上使用最大内容宽度和自适应 margin,避免固定像素导致小屏挤压。各设置组默认响应式 3 列表单:短字段占 1 格,长字段(URL/API Key/路径)跨 2 格或 3 格,窄窗口降为 2 列/1 列。点击「保存设置」成功后,调用 `QMessageBox.information` 弹出“设置已保存”轻量提示框,同时保留状态栏提示。T-531 已完成:`save_app_settings()` 返回 bool,成功写 `data/config.json` + `data/config/cmhub.json` 后清 dirty,失败保留 dirty 并让调用方阻止离开。
|
||||
- `SettingsTab` 的生成网关配置:用 `gatewayDefaultButton` / `gatewayCustomButton` 分段选择器表达 `cmhub` / `direct` 来源,选择仅标记待保存,保存后才写 `ai.backend`。默认网关 Base URL 保存/刷新前规整为网关根,API Key 单独读写 `data/config/cmhub.json`,别名下拉来自 `fetch_cmhub_models()`;自定义网关复用 `data/config/ai_models.json` 的模型 CRUD 与文本/图片角色选择。两套配置并存,模型 CRUD 立即保存;`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存。`settingsSaved` 通知主窗口刷新⑥能力状态;`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于保存和放弃来源选择。
|
||||
- `SettingsTab` 的生成网关配置:用 `gatewayDefaultButton` / `gatewayCustomButton` 分段选择器表达 `cmhub` / `direct` 来源,选择仅标记待保存,保存后才写 `ai.backend`。默认网关 Base URL 保存/刷新前规整为网关根,API Key 单独读写 `data/config/cmhub.json`,别名下拉来自 `fetch_cmhub_models()`;自定义网关复用 `data/config/ai_models.json` 的模型 CRUD 与文本/图片角色选择。图片模型只展示「OpenAI 图片编辑接口」;历史不兼容配置保留并标示。图片模型的「检查图片配置」只做本地校验,不发送网络图片请求。两套配置并存,模型 CRUD 立即保存;`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存。`settingsSaved` 通知主窗口刷新⑥能力状态;`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于保存和放弃来源选择。
|
||||
- T-532 要求 `SettingsTab._on_cmhub_finished()` 从 worker payload 的 `balance` / user/account 字段提取 cmhub 账号身份,成功文案优先显示 `cmhub 账号「<账号名>」连接成功:...`;当前 `/balance` 结构兼容 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }`,显示名优先 `account.display_name`,再兜底 `account.username` / `user` / 顶层常见字段;账号字段缺失时保持 `cmhub 连接成功:...`。显示名必须脱敏处理邮箱,且不得把 API Key、token 或完整敏感响应写入 GUI、run log 或诊断日志。
|
||||
- `MainWindow` 已负责设置页离开守卫:切 Tab 与 `closeEvent` 发现 `SettingsTab.is_dirty()` 时弹保存/放弃/取消;保存成功后继续,保存失败或取消时回到设置。由于 `QTabWidget.currentChanged` 是切换后信号,需维护上一个 index,并用 `_reverting_tab_change` 或等价 guard 防止 `setCurrentIndex()` 递归。
|
||||
- 模型详情字段按 3 个组件一组排列:启用、类别、api_type、连接超时等短字段一格;服务商名、模型 ID 视宽度占一格或两格;网址、密钥跨整行或跨 2/3 列。
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
> **v3.6 修订(2026-07-08,T-564)**:cmhub 已新增异步生图任务接口,②批量生图改为 `POST /api/v1/generate/image/tasks` submit + `GET /api/v1/generate/image/tasks/{task_id}` poll;cmshopee 持久化 `tasks.image_task_id/image_task_key`,支持停止/超时/重启后续查,避免 900 秒同步长连接和读超时重复扣点。旧同步 `POST /api/v1/generate/image` 仅保留给单独 `gen_cover()` 兼容/回滚路径。
|
||||
> **v3.7 修订(2026-07-17,T-645)**:⑥「商品套图」的 AI帮写接入独立图片理解能力:使用 `vision_alias` 调 `POST /api/v1/analyze/images`,不复用标题接口或 `title_alias`。请求最多8张有序本地原图,单图不超过10MiB、总计不超过32MiB;读取等待120秒且读超时不自动重发,避免结果未确认时重复扣点。
|
||||
> **v3.8 修订(2026-07-20,T-677)**:T-529 的普通 UI 单来源收口有限放开为「默认网关 / 自定义网关」分段选择器,来源仅在保存设置后切换。自定义网关是客户端直连的过渡兼容路径,只覆盖②生文、生图;模型清单与默认网关配置并存。⑥商品套图和图片理解仍只允许默认网关新建请求,但此前已由默认网关接受、且同时带 `generation_source=cmhub`、`provider=cmhub` 与 `task_id` 的套图 job 可继续轮询、下载、保存,避免已扣点结果丢失。运行中的 worker 冻结来源、模型与密钥的内存快照,后续保存设置不影响本轮;密钥不写入 SQLite、日志或 UI。
|
||||
> **v3.9 修订(2026-07-20,T-678)**:自定义网关的图片生成固定采用 OpenAI 图片编辑契约:`POST /v1/images/edits`、multipart 有序重复 `image[]`、第一张为业务主体图、`n=1`,仅从 `data[].b64_json` 或 `data[].url` 读取结果。`category=image` 只有 `api_type=images_edits` 可发起生成;历史 `chat` / `auto` 图片模型保留可查看但在设置和生成预检中明确为不支持,绝不静默迁移。默认网关的 cmhub 请求、异步任务与宽松响应解析不受该收敛影响。⑤对图片模型只做本地配置检查,不发送可能计费的空图片请求;实际可用性通过②或后续⑥的正常生成确认验证。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
|
||||
+2
-2
@@ -173,8 +173,8 @@
|
||||
- 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。T-531 已完成:保存成功会清除未保存标记;保存失败时保留未保存标记并阻止离开。
|
||||
- 生成网关(T-677):用具备选中态的「默认网关 / 自定义网关」来源选择器替代历史「AI 后端」下拉。切换只改变待保存状态并立即切换面板,点击「保存设置」才写入 `ai.backend=cmhub/direct`;默认网关的 `data/config/cmhub.json` 与自定义模型的 `data/config/ai_models.json` 始终并存,模型新增、保存、删除仍立即写盘,不受来源待保存语义影响。
|
||||
- 默认网关面板展示 Base URL、API Key、生文/生图/图片理解别名、连接超时、批量生成前检查余额、刷新别名和测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前规整掉 `/api`、`/api/v1` 或其它路径。测试连接成功优先显示 `账号「<账号名>」连接默认网关成功`,邮箱脱敏;没有账号信息时显示「默认网关连接成功」。Key 只本地明文保存、UI 打码显示,不进入日志或导出。
|
||||
- 自定义网关复用既有模型列表、模型详情、默认文本模型、默认图像模型和模型连接测试;它仅兼容现有 direct 标题/封面请求,`chat/auto` 走聊天请求、`images_edits` 走图片编辑请求,不承诺自动探测或所有 OpenAI 兼容服务可用。说明行明确「图片理解与商品套图仅支持默认网关」。
|
||||
- 允许保存不完整配置。②点击生成后、自定义 worker 创建前按本轮标题/封面选择校验所需模型的启用状态、类别、地址、模型 ID、API Key 和接口类型,失败提示去⑤补齐且不发请求、不改任务状态;自定义网关不显示默认网关余额,运行日志和状态栏说明“不计点数,费用由服务商收取”。
|
||||
- 自定义网关复用既有模型列表、模型详情、默认文本模型与默认图像模型。文本模型保留 `chat` / `auto`;图片模型固定使用「OpenAI 图片编辑接口」(`images_edits`),②通过 `/v1/images/edits` 以有序重复 `image[]` 上传,第一张是商品主体图、每次只请求 `n=1` 输出。历史 `chat` / `auto` 图片模型不会被改写或隐藏,但会标示「图片接口不支持」,生成前会被拦截。图片响应只接受 `data[].b64_json` / `data[].url`,不适配聊天响应或服务商私有结构。说明行仍明确「图片理解与商品套图仅支持默认网关」。
|
||||
- 图片模型按钮显示「检查图片配置」,只在本地检查接口类型、URL、模型 ID、密钥和超时,绝不对 `/images/edits` 发送无图片的测试请求;实际可用性必须以②或后续⑥的正常生成确认判断。允许保存不完整配置。②点击生成后、自定义 worker 创建前按本轮标题/封面选择校验所需模型的启用状态、类别、地址、模型 ID、API Key 和接口类型,失败提示去⑤补齐且不发请求、不改任务状态;自定义网关不显示默认网关余额,运行日志和状态栏说明“不计点数,费用由服务商收取”。
|
||||
- ⑥商品套图在自定义网关下禁用新建、重新生成和 AI帮写;但已有 `generation_source=cmhub`、`provider=cmhub` 且 `task_id` 非空的可恢复任务仍显示「继续查询已提交图片」,仅轮询、下载和保存,不新建 job 或重复扣点。若默认网关配置已不可用,提示恢复原默认网关配置。所有②/⑥ worker 开始时冻结来源、实际模型和密钥的仅内存快照,保存设置不会改变正在执行的一轮。
|
||||
- T-531 已完成:设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。
|
||||
- 自定义模型清单继续保存于 `data/config/ai_models.json`;在自定义网关面板中可维护并立即保存,切换回默认网关不会清除这些配置。
|
||||
|
||||
+6
-2
@@ -3,7 +3,7 @@ id: T-678
|
||||
title: 自定义网关图片模型锁定 OpenAI 图片编辑接口并支持多图输入
|
||||
phase: 7
|
||||
deps: [T-677]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-07-20
|
||||
---
|
||||
|
||||
@@ -88,4 +88,8 @@ git diff --check
|
||||
|
||||
## 执行记录
|
||||
|
||||
(做完在这里写:改了什么文件、跑了什么验证命令及结果、官方接口契约核实来源与日期、遇到的阻塞和关键决策。)
|
||||
- 2026-07-20 完成:保留全局 `API_TYPES`,新增按图片类别的 OpenAI 图片编辑接口校验;存量 `chat` / `auto` 图片模型仍可载入、查看和保存,但设置页标示为不支持,②预检及 direct 生图会在发请求前用中文拦截。
|
||||
- direct 封面请求统一使用 `/v1/images/edits` multipart;`_image_edit_body()` 支持有序多图,重复 `image[]` 字段的第一张为主体图,并固定 `n=1`。响应解析收敛到 `data[].b64_json` / `data[].url`;cmhub 保持独立的宽松解析函数,默认网关行为未改变。
|
||||
- 图片模型按钮改为「检查图片配置」,worker 只调用本地字段、接口类型、URL 和超时检查,不发送空图片请求;文字模型继续走既有连接测试。
|
||||
- 文档同步:`docs/cmhub-integration-design.md`、`docs/routes.md`、`docs/api.md`。接口契约于 2026-07-20 参考 [OpenAI Images API 文档](https://platform.openai.com/docs/guides/image-generation):图片编辑使用 multipart 图片输入,图片结果使用 base64 或 URL 表达;本项目固定采用 `image[]`、`n=1` 和标准 `data` 结果字段,不适配服务商私有响应。
|
||||
- 验证通过:`py -3.10 -m unittest tests.test_ai tests.test_appconfig tests.test_gui`(287 passed);`py -3.10 -m unittest discover -s tests`(643 passed);`py -3.10 -m ruff check app tests main.py`;`py -3.10 -m compileall app main.py`;`git diff --check`。本任务未改 CDP,未作线上蝦皮实跑。
|
||||
|
||||
+47
-5
@@ -69,10 +69,10 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
"name": "Image",
|
||||
"category": "image",
|
||||
"enabled": True,
|
||||
"url": "https://example.invalid/v1/chat/completions",
|
||||
"url": "https://example.invalid/v1",
|
||||
"model": "image-model",
|
||||
"api_key": "sk-image-secret",
|
||||
"api_type": "auto",
|
||||
"api_type": "images_edits",
|
||||
"connect_timeout_seconds": 1,
|
||||
"timeout_seconds": 1,
|
||||
"extra_body": {},
|
||||
@@ -378,9 +378,13 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
b64_image = base64.b64encode(generated.getvalue()).decode("ascii")
|
||||
|
||||
def fake_urlopen(request, timeout=None):
|
||||
body = json.loads(request.data.decode("utf-8"))
|
||||
self.assertEqual("image-model", body["model"])
|
||||
self.assertIn("目标分辨率:512", body["messages"][0]["content"][0]["text"])
|
||||
body = request.data
|
||||
self.assertIn(b'name="model"', body)
|
||||
self.assertIn(b"image-model", body)
|
||||
self.assertIn(b'name="image[]"', body)
|
||||
self.assertIn(b'name="n"', body)
|
||||
self.assertIn(b"\r\n1\r\n", body)
|
||||
self.assertIn("multipart/form-data", request.headers["Content-type"])
|
||||
return _Response({"data": [{"b64_json": b64_image}]})
|
||||
|
||||
with mock.patch("app.ai.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
@@ -399,6 +403,44 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_image_edit_body_keeps_repeated_image_fields_in_order(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
first = os.path.join(temp_dir, "primary.jpg")
|
||||
second = os.path.join(temp_dir, "reference.png")
|
||||
with open(first, "wb") as fh:
|
||||
fh.write(b"first-image")
|
||||
with open(second, "wb") as fh:
|
||||
fh.write(b"second-image")
|
||||
|
||||
body, content_type = ai._image_edit_body(
|
||||
{"model": "image-model", "extra_body": {"quality": "high"}},
|
||||
"生成商品图",
|
||||
[first, second],
|
||||
"1k",
|
||||
)
|
||||
|
||||
self.assertIn("multipart/form-data", content_type)
|
||||
self.assertEqual(2, body.count(b'name="image[]"'))
|
||||
self.assertLess(body.index(b"primary.jpg"), body.index(b"reference.png"))
|
||||
self.assertIn(b'name="n"', body)
|
||||
self.assertIn(b"\r\n1\r\n", body)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_direct_image_response_only_accepts_openai_data_fields(self):
|
||||
encoded = base64.b64encode(b"image-bytes").decode("ascii")
|
||||
|
||||
self.assertEqual(encoded, ai._find_image_ref({"data": [{"b64_json": encoded}]}))
|
||||
self.assertEqual(
|
||||
"https://images.example.com/new.png",
|
||||
ai._find_image_ref({"data": [{"url": "https://images.example.com/new.png"}]}),
|
||||
)
|
||||
self.assertIsNone(ai._find_image_ref({"choices": [{"image_url": "x"}]}))
|
||||
with self.assertRaisesRegex(ai.AIError, "OpenAI 图片编辑接口"):
|
||||
ai._extract_image_bytes({"message": {"url": "https://bad.example/x"}}, {}, {})
|
||||
with self.assertRaisesRegex(ai.AIError, "只允许 http/https"):
|
||||
ai._extract_image_bytes({"data": [{"url": "file:///tmp/image.png"}]}, {}, {})
|
||||
|
||||
def test_cmhub_gen_title_uses_alias_and_emits_metadata(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg, key_path = self._cmhub_config(temp_dir)
|
||||
|
||||
@@ -635,6 +635,40 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_image_model_config_check_keeps_legacy_models_but_blocks_them_for_generation(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
models_path = os.path.join(temp_dir, "ai_models.json")
|
||||
config = appconfig.default_ai_models_config()
|
||||
legacy_image = config["models"][1]
|
||||
legacy_image.update(
|
||||
{
|
||||
"url": "https://legacy.example.com/v1/chat/completions",
|
||||
"model": "legacy-image",
|
||||
"api_key": "sk-legacy-secret",
|
||||
"api_type": "auto",
|
||||
}
|
||||
)
|
||||
appconfig.save_ai_models_config(config, path=models_path)
|
||||
|
||||
loaded = appconfig.get_model("Nano Banana 2", path=models_path)
|
||||
self.assertEqual("auto", loaded["api_type"])
|
||||
result = appconfig.check_image_model_config("Nano Banana 2", path=models_path)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertTrue(result["check_only"])
|
||||
self.assertIn("OpenAI 图片编辑接口", result["error"])
|
||||
self.assertNotIn("sk-legacy-secret", result["error"])
|
||||
|
||||
appconfig.update_ai_model(
|
||||
"Nano Banana 2",
|
||||
path=models_path,
|
||||
api_type="images_edits",
|
||||
)
|
||||
self.assertTrue(
|
||||
appconfig.check_image_model_config("Nano Banana 2", path=models_path)["ok"]
|
||||
)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_ai_model_test_uses_resolved_base_url(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
models_path = os.path.join(temp_dir, "ai_models.json")
|
||||
|
||||
+74
-1
@@ -2232,6 +2232,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
models_path = cfg["ai_models_path"]
|
||||
statuses = []
|
||||
appconfig.save_ai_models_config(
|
||||
{
|
||||
"models": [
|
||||
@@ -2264,7 +2265,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
path=models_path,
|
||||
)
|
||||
|
||||
tab = SettingsTab(config=cfg, ai_models_path=models_path)
|
||||
tab = SettingsTab(
|
||||
config=cfg,
|
||||
ai_models_path=models_path,
|
||||
status_callback=statuses.append,
|
||||
)
|
||||
self.addCleanup(tab.close)
|
||||
|
||||
self.assertEqual(2, tab.model_combo.count())
|
||||
@@ -2523,6 +2528,74 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_settings_tab_image_model_only_offers_image_edit_and_checks_locally(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
models_path = cfg["ai_models_path"]
|
||||
statuses = []
|
||||
appconfig.save_ai_models_config(
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "Text A",
|
||||
"category": "text",
|
||||
"enabled": True,
|
||||
"url": "https://example.invalid/v1",
|
||||
"model": "text-model",
|
||||
"api_key": "sk-text",
|
||||
"api_type": "chat",
|
||||
"connect_timeout_seconds": 30,
|
||||
"timeout_seconds": 0,
|
||||
"extra_body": {},
|
||||
},
|
||||
{
|
||||
"name": "Legacy Image",
|
||||
"category": "image",
|
||||
"enabled": True,
|
||||
"url": "https://example.invalid/v1/chat/completions",
|
||||
"model": "image-model",
|
||||
"api_key": "sk-image",
|
||||
"api_type": "auto",
|
||||
"connect_timeout_seconds": 30,
|
||||
"timeout_seconds": 0,
|
||||
"extra_body": {},
|
||||
},
|
||||
]
|
||||
},
|
||||
path=models_path,
|
||||
)
|
||||
tab = SettingsTab(
|
||||
config=cfg,
|
||||
ai_models_path=models_path,
|
||||
status_callback=statuses.append,
|
||||
)
|
||||
self.addCleanup(tab.close)
|
||||
tab.model_combo.setCurrentIndex(tab.model_combo.findData("Legacy Image"))
|
||||
|
||||
self.assertIn("当前图片模型不支持 OpenAI 图片编辑接口", tab.model_combo.currentText())
|
||||
self.assertEqual("auto", tab.api_type_combo.currentData())
|
||||
self.assertEqual("检查图片配置", tab.test_connection_button.text())
|
||||
self.assertIn("当前不支持", tab.api_type_combo.currentText())
|
||||
|
||||
class FakeSignal:
|
||||
def connect(self, callback):
|
||||
self.callback = callback
|
||||
|
||||
class FakeThread:
|
||||
def __init__(self):
|
||||
self.finished = FakeSignal()
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
with mock.patch("app.gui.run_worker", return_value=FakeThread()):
|
||||
tab.test_connection()
|
||||
|
||||
self.assertTrue(tab.test_worker.check_image_config)
|
||||
self.assertIn("正在检查图片模型配置", statuses[-1])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_settings_tab_saves_role_generation_path_and_port_config(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
|
||||
Reference in New Issue
Block a user