feat(product-suite): add cmhub vision AI writing

This commit is contained in:
chengma
2026-07-17 09:09:09 +08:00
parent 56d6b59a00
commit 40a9f5fbf2
15 changed files with 575 additions and 40 deletions
+127 -6
View File
@@ -47,6 +47,10 @@ class CMHubError(AIError):
CMHUB_IMAGE_MAX_BYTES = 20 * 1024 * 1024
CMHUB_TITLE_READ_TIMEOUT_SECONDS = 600
CMHUB_VISION_READ_TIMEOUT_SECONDS = 120
CMHUB_VISION_MAX_IMAGES = 8
CMHUB_VISION_MAX_IMAGE_BYTES = 10 * 1024 * 1024
CMHUB_VISION_MAX_TOTAL_BYTES = 32 * 1024 * 1024
CMHUB_IMAGE_READ_TIMEOUT_SECONDS = 900
CMHUB_IMAGE_SUBMIT_READ_TIMEOUT_SECONDS = 36
CMHUB_IMAGE_POLL_READ_TIMEOUT_SECONDS = 15
@@ -162,6 +166,55 @@ def gen_title(
return text
def analyze_product_images(
instruction,
context,
image_paths,
*,
config=None,
cmhub_config_path=appconfig.CMHUB_CONFIG_PATH,
on_event=None,
):
"""Use the dedicated cmhub vision route for product-suite AI writing."""
cfg = appconfig.load_config() if config is None else config
ai_cfg = appconfig.ai_config(cfg)
if _ai_backend(ai_cfg) != "cmhub":
raise AIError("商品套图AI帮写需要使用 cmhub 图片理解服务,请到⑤设置配置 cmhub。")
paths = _validate_cmhub_vision_images(image_paths)
runtime = _cmhub_runtime(cfg, "vision", cmhub_config_path)
payload = {
"prompt": _compose_product_suite_vision_prompt(instruction, context),
"model": runtime["alias"],
"images": [{"image_base64": _image_data_url(path)} for path in paths],
"parameters": {"temperature": 0.2},
}
try:
data = _cmhub_call_with_retry(
"POST",
appconfig.cmhub_request_url(runtime["base_url"], "/api/v1/analyze/images"),
runtime["api_key"],
payload=payload,
connect_timeout=runtime["connect_timeout"],
read_timeout=CMHUB_VISION_READ_TIMEOUT_SECONDS,
attempts=1,
on_retry=None,
)
except CMHubError as exc:
raise _vision_cmhub_error(exc) from exc
_emit_cmhub_metadata(on_event, data, "vision_request")
text = _extract_text(data).strip()
if not text:
raise AIError("图片理解服务未返回可用卖点,请稍后重试")
return {
"text": text,
"image_count": len(paths),
"metadata": _cmhub_metadata(data),
}
def gen_cover(
cover_prompt,
old_cover_path,
@@ -1338,17 +1391,67 @@ def _download_and_save_cmhub_cover(request_result, on_step=None):
return saved_path
def _validate_cmhub_vision_images(image_paths):
paths = [str(path or "").strip() for path in list(image_paths or [])]
if not paths:
raise AIError("请先添加至少一张可用商品原图")
if len(paths) > CMHUB_VISION_MAX_IMAGES:
raise AIError("图片理解最多支持%d张商品原图" % CMHUB_VISION_MAX_IMAGES)
total_size = 0
for index, path in enumerate(paths, 1):
if not path or not os.path.isfile(path):
raise AIError("第%d张商品原图尚未下载完成,请稍后重试" % index)
try:
size = os.path.getsize(path)
except OSError as exc:
raise AIError("第%d张商品原图无法读取,请稍后重试" % index) from exc
if size > CMHUB_VISION_MAX_IMAGE_BYTES:
raise AIError("第%d张商品原图超过10MiB,无法进行AI帮写" % index)
total_size += size
if total_size > CMHUB_VISION_MAX_TOTAL_BYTES:
raise AIError("商品原图总大小超过32MiB,无法进行AI帮写")
return paths
def _vision_cmhub_error(exc):
code = str(getattr(exc, "code", "") or "unknown")
if code == "read_timeout":
message = "等待图片理解结果超时,结果未确认,请先查看点数余额或稍后重试"
elif code in {"connect_timeout", "network_error"}:
message = "连接图片理解服务失败,结果未确认,请检查网络后稍后重试"
else:
message = _cmhub_user_message(code, "")
if not message or message == code:
message = "图片理解服务暂时不可用,请稍后重试"
return CMHubError(
code,
message,
status=getattr(exc, "status", None),
retryable=False,
retry_after=getattr(exc, "retry_after", None),
)
def _cmhub_runtime(config, operation, cmhub_config_path):
hub = appconfig.cmhub_config(config)
api_key = appconfig.get_cmhub_api_key(path=cmhub_config_path)
alias_key = "title_alias" if operation == "title" else "image_alias"
operation_config = {
"title": ("title_alias", "生文别名"),
"image": ("image_alias", "生图别名"),
"vision": ("vision_alias", "图片理解别名"),
}
try:
alias_key, alias_label = operation_config[str(operation or "")]
except KeyError as exc:
raise AIError("cmhub 操作类型无效") from exc
missing = []
if not hub.get("base_url"):
missing.append("Base URL")
if not api_key:
missing.append("API Key")
if not hub.get(alias_key):
missing.append("生文别名" if operation == "title" else "生图别名")
missing.append(alias_label)
if missing:
raise CMHubError(
"cmhub_not_configured",
@@ -1379,6 +1482,18 @@ def _compose_title_prompt(title_prompt, old_title):
return "请只返回新标题,不要解释。"
def _compose_product_suite_vision_prompt(instruction, context):
return (
"请分析当前电商商品原图,并根据图片可见信息生成可直接编辑的「商品卖点与要求」。"
"请使用任务指定的输出语言,只输出卖点与画面要求正文,不要解释分析过程。"
"内容应包含商品名称或品类、颜色、款式、可见细节、核心卖点、目标人群、使用场景"
"和适合套图生成的画面要求。已有要求仅是补充约束,不能当作图片事实。"
"不要虚构材质、尺寸、功能、认证、价格、物流承诺或图片中不可确认的信息。"
"\n\n任务要求:\n%s\n\n任务上下文:\n%s"
% (str(instruction or "").strip(), str(context or "").strip())
)
def _normalize_cmhub_resolution(resolution):
value = str(resolution or "1k").strip().lower()
mapping = {
@@ -1697,14 +1812,20 @@ def _notify_cmhub_retry(callback, step, attempt, attempts, exc):
pass
def _emit_cmhub_metadata(callback, data, step):
if callback is None or not isinstance(data, dict):
return
metadata = {
def _cmhub_metadata(data):
if not isinstance(data, dict):
return {}
return {
key: data.get(key)
for key in ("alias", "model_used", "points_cost", "points_balance", "call_id")
if data.get(key) is not None
}
def _emit_cmhub_metadata(callback, data, step):
if callback is None:
return
metadata = _cmhub_metadata(data)
if not metadata:
return
try:
+5
View File
@@ -88,6 +88,7 @@ DEFAULT_CONFIG = {
"base_url": "",
"title_alias": "",
"image_alias": "",
"vision_alias": "vision-standard",
"connect_timeout": CMHUB_CONNECT_TIMEOUT_DEFAULT,
"use_system_proxy": False,
"download_with_curl": "auto",
@@ -406,6 +407,9 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False):
cmhub = ai.get("cmhub")
if isinstance(cmhub, dict):
cmhub["base_url"] = normalize_cmhub_base_url(cmhub.get("base_url", ""))
cmhub["title_alias"] = str(cmhub.get("title_alias", "") or "").strip()
cmhub["image_alias"] = str(cmhub.get("image_alias", "") or "").strip()
cmhub["vision_alias"] = str(cmhub.get("vision_alias", "") or "").strip()
cmhub["connect_timeout"] = _normalize_cmhub_connect_timeout(
cmhub.get("connect_timeout"),
migrate_old_default=migrate_old_cmhub_connect_timeout,
@@ -807,6 +811,7 @@ def cmhub_config(config=None) -> dict:
merged["base_url"] = normalize_cmhub_base_url(merged.get("base_url", ""))
merged["title_alias"] = str(merged.get("title_alias", "") or "").strip()
merged["image_alias"] = str(merged.get("image_alias", "") or "").strip()
merged["vision_alias"] = str(merged.get("vision_alias", "") or "").strip()
merged["connect_timeout"] = _normalize_cmhub_connect_timeout(
merged.get("connect_timeout"),
migrate_old_default=False,
+28 -7
View File
@@ -54,6 +54,7 @@ from PySide6.QtWidgets import (
from ... import (
accounts,
ai,
appconfig,
diagnostics,
image_studio,
@@ -3386,8 +3387,9 @@ class ProductSuiteTab(QWidget):
return
local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)]
if not local_assets:
self._message("缺少商品原图", "请先添加或拉取至少一张可用商品原图。")
self._message("缺少可用商品原图", "请先添加商品原图,或等待已拉取的商品原图下载完成。")
return
selected_assets = local_assets[: ai.CMHUB_VISION_MAX_IMAGES]
self._save_controls_to_state(state)
context = (
"商品ID:%s;平台:%s;国家地区:%s;输出语言:%s。当前已有要求:%s"
@@ -3400,15 +3402,15 @@ class ProductSuiteTab(QWidget):
)
)
instruction = (
"请为电商商品图片生成简洁、可执行的商品卖点与画面要求。"
"只输出可直接用于生图的中文提示词,包含核心卖点、目标人群、使用场景和禁用元素,"
"不要虚构商品参数。"
"请为当前商品原图生成简洁、可执行的商品卖点与画面要求。"
"内容应包含核心卖点、目标人群、使用场景和禁用元素,不要虚构商品参数。"
)
state.ai_prompt_snapshot = state.prompt
state.ai_started_at = time.monotonic()
worker = ProductSuiteAiWriteWorker(
instruction,
context,
image_paths=[asset.local_path for asset in selected_assets],
config=self.config,
cmhub_config_path=self.cmhub_config_path,
)
@@ -3419,7 +3421,10 @@ class ProductSuiteTab(QWidget):
state.ai_thread = self._start_thread(worker, "商品套图AI帮写")
if state is self._displayed_state:
self._apply_running_state(state)
self._status("AI帮写已开始,可切换到其他套图任务", "info")
message = "AI帮写已开始,可切换到其他套图任务"
if len(local_assets) > ai.CMHUB_VISION_MAX_IMAGES:
message += ";已使用前%d张商品原图进行理解" % ai.CMHUB_VISION_MAX_IMAGES
self._status(message, "info")
def cancel_ai_write(self, checked=False):
state = self._displayed_state
@@ -3444,7 +3449,11 @@ class ProductSuiteTab(QWidget):
if state is self._displayed_state:
self._offer_pending_ai_result(state)
else:
self._status("AI帮写已完成,切回对应套图任务后确认是否采用", "success")
self._status(
self._ai_write_success_message(result)
+ ",切回对应套图任务后确认是否采用",
"success",
)
else:
state.prompt = text
self._persist_state(state)
@@ -3454,10 +3463,22 @@ class ProductSuiteTab(QWidget):
self.prompt_edit.setPlainText(text)
finally:
self._loading = False
self._status("AI帮写已完成", "success")
self._status(self._ai_write_success_message(result), "success")
if state is self._displayed_state:
self._apply_running_state(state)
def _ai_write_success_message(self, result):
image_count = max(0, int(dict(result or {}).get("image_count", 0) or 0))
parts = ["AI帮写完成"]
if image_count:
parts.append("已理解%d张商品原图" % image_count)
metadata = dict(dict(result or {}).get("metadata") or {})
if metadata.get("points_cost") is not None:
parts.append("图片理解扣点%s" % metadata["points_cost"])
if metadata.get("points_balance") is not None:
parts.append("当前余额%s" % metadata["points_balance"])
return ":".join(parts[:2]) + ("," + ",".join(parts[2:]) if len(parts) > 2 else "")
def _offer_pending_ai_result(self, state):
if not state.pending_ai_text:
return
+55 -5
View File
@@ -88,6 +88,8 @@ class SettingsTab(QWidget):
self.cmhub_title_alias_combo.setObjectName("cmhubTitleAliasCombo")
self.cmhub_image_alias_combo = QComboBox()
self.cmhub_image_alias_combo.setObjectName("cmhubImageAliasCombo")
self.cmhub_vision_alias_combo = QComboBox()
self.cmhub_vision_alias_combo.setObjectName("cmhubVisionAliasCombo")
self.cmhub_connect_timeout_spin = QSpinBox()
self.cmhub_connect_timeout_spin.setObjectName("cmhubConnectTimeoutSpin")
self.cmhub_connect_timeout_spin.setRange(1, 3600)
@@ -304,6 +306,7 @@ class SettingsTab(QWidget):
("连接超时(秒)", self.cmhub_connect_timeout_spin),
("生文别名", self.cmhub_title_alias_combo),
("生图别名", self.cmhub_image_alias_combo),
("图片理解别名", self.cmhub_vision_alias_combo),
("", self.cmhub_check_balance_checkbox),
]
)
@@ -480,6 +483,7 @@ class SettingsTab(QWidget):
self.backend_combo,
self.cmhub_title_alias_combo,
self.cmhub_image_alias_combo,
self.cmhub_vision_alias_combo,
self.category_combo,
self.api_type_combo,
self.default_text_model_combo,
@@ -814,6 +818,7 @@ class SettingsTab(QWidget):
"base_url": appconfig.normalize_cmhub_base_url(self.cmhub_base_url_edit.text()),
"title_alias": self.cmhub_title_alias_combo.currentData() or "",
"image_alias": self.cmhub_image_alias_combo.currentData() or "",
"vision_alias": self.cmhub_vision_alias_combo.currentData() or "",
"connect_timeout": self.cmhub_connect_timeout_spin.value(),
"check_balance_before_batch": self.cmhub_check_balance_checkbox.isChecked(),
}
@@ -866,6 +871,7 @@ class SettingsTab(QWidget):
self.cmhub_models,
title_selected=cmhub_cfg.get("title_alias", ""),
image_selected=cmhub_cfg.get("image_alias", ""),
vision_selected=cmhub_cfg.get("vision_alias", ""),
)
self._set_combo_by_data(
self.default_text_model_combo,
@@ -1127,6 +1133,7 @@ class SettingsTab(QWidget):
self.cmhub_api_key_edit,
self.cmhub_title_alias_combo,
self.cmhub_image_alias_combo,
self.cmhub_vision_alias_combo,
self.cmhub_connect_timeout_spin,
self.cmhub_check_balance_checkbox,
self.cmhub_refresh_button,
@@ -1149,17 +1156,27 @@ class SettingsTab(QWidget):
current_cfg = appconfig.cmhub_config(self.config)
title_selected = self.cmhub_title_alias_combo.currentData() or current_cfg.get("title_alias", "")
image_selected = self.cmhub_image_alias_combo.currentData() or current_cfg.get("image_alias", "")
vision_selected = self.cmhub_vision_alias_combo.currentData() or current_cfg.get("vision_alias", "")
self._populate_cmhub_alias_combos(
models,
title_selected=title_selected,
image_selected=image_selected,
vision_selected=vision_selected,
)
title_count = self._cmhub_alias_count("title")
image_count = self._cmhub_alias_count("image")
vision_aliases = {
str(model.get("alias") or "").strip()
for model in self._cmhub_priced_models(models, "vision")
}
vision_count = len(vision_aliases)
balance = payload.get("points_balance")
balance_text = f";余额 {balance}" if balance is not None else ""
subject = self._cmhub_success_subject(payload)
message = f"{subject}:生文别名 {title_count} 个,生图别名 {image_count} 个{balance_text};别名已拉取,记得点『保存设置』持久化"
vision_text = f"图片理解别名 {vision_count} 个"
if vision_selected and vision_selected not in vision_aliases:
vision_text += "(当前已保存值暂不可用)"
message = f"{subject}:生文别名 {title_count} 个,生图别名 {image_count} 个,{vision_text}{balance_text};别名已拉取,记得点『保存设置』持久化"
self.cmhub_result_label.setText(message)
self._set_status(message)
@@ -1224,10 +1241,21 @@ class SettingsTab(QWidget):
self.cmhub_result_label.setText(message)
self._set_status(message)
def _populate_cmhub_alias_combos(self, models, title_selected="", image_selected=""):
def _populate_cmhub_alias_combos(
self,
models,
title_selected="",
image_selected="",
vision_selected="",
):
if self._suspend_dirty <= 0:
with self._dirty_tracking_suspended():
self._populate_cmhub_alias_combos(models, title_selected, image_selected)
self._populate_cmhub_alias_combos(
models,
title_selected,
image_selected,
vision_selected,
)
return
self._populate_cmhub_alias_combo(
self.cmhub_title_alias_combo,
@@ -1241,6 +1269,12 @@ class SettingsTab(QWidget):
"image",
image_selected,
)
self._populate_cmhub_alias_combo(
self.cmhub_vision_alias_combo,
models,
"vision",
vision_selected,
)
def _populate_cmhub_alias_combo(self, combo, models, operation, selected):
combo.blockSignals(True)
@@ -1270,12 +1304,28 @@ class SettingsTab(QWidget):
alias = str(model.get("alias") or "").strip()
op = str(model.get("operation_type") or "").lower()
pricing_status = str(model.get("pricing_status") or "").lower()
if alias and op == operation and pricing_status != "unpriced":
requires_image = bool(model.get("requires_image"))
if (
alias
and op == operation
and pricing_status != "unpriced"
and (
operation != "vision"
or (pricing_status == "priced" and requires_image)
)
):
items.append(model)
return items
def _cmhub_alias_count(self, operation):
combo = self.cmhub_title_alias_combo if operation == "title" else self.cmhub_image_alias_combo
combos = {
"title": self.cmhub_title_alias_combo,
"image": self.cmhub_image_alias_combo,
"vision": self.cmhub_vision_alias_combo,
}
combo = combos.get(operation)
if combo is None:
return 0
return sum(1 for index in range(combo.count()) if combo.itemData(index))
def _show_error(self, error):
+20 -4
View File
@@ -419,34 +419,41 @@ class ProductSuiteGenerateWorker(BaseWorker):
class ProductSuiteAiWriteWorker(BaseWorker):
"""Generate product selling-point copy without blocking the suite workspace."""
"""Analyze local product images without blocking the suite workspace."""
def __init__(
self,
instruction,
context,
*,
image_paths=None,
config=None,
cmhub_config_path=None,
):
super().__init__()
self.instruction = str(instruction or "")
self.context = str(context or "")
self.image_paths = [str(path or "") for path in list(image_paths or [])]
self.config = config
self.cmhub_config_path = cmhub_config_path
def execute(self):
if self.should_cancel():
return {"cancelled": True}
text = ai.gen_title(
result = ai.analyze_product_images(
self.instruction,
self.context,
self.image_paths,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
)
if self.should_cancel():
return {"cancelled": True}
return {"text": str(text or "").strip()}
return {
"text": str(result.get("text") or "").strip(),
"image_count": int(result.get("image_count", 0) or 0),
"metadata": dict(result.get("metadata") or {}),
}
class ProductSuiteImportImagesWorker(BaseWorker):
@@ -2875,10 +2882,12 @@ class CMHubSettingsWorker(BaseWorker):
}
title_count = self._priced_count(models, "title")
image_count = self._priced_count(models, "image")
vision_count = self._priced_count(models, "vision")
self._log_run_event(
"step=cmhub_settings result=success detail=title_aliases={title_count} image_aliases={image_count} points_balance={points_balance} elapsed_ms={elapsed_ms}".format(
"step=cmhub_settings result=success detail=title_aliases={title_count} image_aliases={image_count} vision_aliases={vision_count} points_balance={points_balance} elapsed_ms={elapsed_ms}".format(
title_count=title_count,
image_count=image_count,
vision_count=vision_count,
points_balance=payload.get("points_balance") if payload.get("points_balance") is not None else "",
elapsed_ms=elapsed_ms,
)
@@ -2900,6 +2909,13 @@ class CMHubSettingsWorker(BaseWorker):
if str(model.get("operation_type") or "").lower() == operation
and str(model.get("pricing_status") or "").lower() != "unpriced"
and str(model.get("alias") or "").strip()
and (
operation != "vision"
or (
str(model.get("pricing_status") or "").lower() == "priced"
and bool(model.get("requires_image"))
)
)
)
def _create_run_log(self):