feat(suite): confirm AI writing cost before request

This commit is contained in:
chengma
2026-07-17 11:48:25 +08:00
parent a06a02ddcf
commit 1ef5721a20
11 changed files with 620 additions and 18 deletions
+1
View File
@@ -20,6 +20,7 @@ if QT_IMPORT_ERROR is None:
from .workers import (
AccountLoginCheckWorker,
AIModelTestWorker,
CMHubModelCatalogWorker,
CMHubSettingsWorker,
ApplyWorker,
CollectWorker,
+183 -10
View File
@@ -56,6 +56,7 @@ from ... import (
accounts,
ai,
appconfig,
cmhub_models,
diagnostics,
image_studio,
image_studio_images,
@@ -67,6 +68,7 @@ from ..image_preview import ImagePreviewDialog
from ..product_suite_prompt_dialog import ProductSuitePromptDialog
from ..widgets import _emit_status, run_worker
from ..workers import (
CMHubModelCatalogWorker,
ImageStudioDownloadOriginalWorker,
ImageStudioPullImagesWorker,
ProductSuiteAiWriteWorker,
@@ -1834,6 +1836,9 @@ class SuiteTaskState:
import_created_draft: bool = False
ai_worker: object = None
ai_thread: object = None
ai_price_worker: object = None
ai_price_thread: object = None
ai_confirmation_open: bool = False
download_queue: list = field(default_factory=list)
downloads: dict = field(default_factory=dict)
download_tokens: dict = field(default_factory=dict)
@@ -2519,8 +2524,9 @@ class ProductSuiteTab(QWidget):
return
state.generation_stop_requested = True
state.worker.cancel()
if state.ai_worker is not None:
state.ai_worker.cancel()
for worker in (state.ai_worker, state.ai_price_worker):
if worker is not None:
worker.cancel()
if state.pull_running():
state.pull_stop_requested = True
state.pull_cleanup_mode = "keep"
@@ -4074,15 +4080,171 @@ class ProductSuiteTab(QWidget):
state = self._displayed_state
if state is None:
return
if state.ai_confirmation_open:
self._status("正在等待AI帮写确认", "info")
return
if state.ai_worker is not None:
self._status("当前套图任务正在AI帮写", "info")
return
local_assets = [asset for asset in self._original_assets(state) if _asset_usable(asset)]
if not local_assets:
if state.ai_price_worker is not None:
self._status("正在读取图片理解预计扣点", "info")
return
selected_assets = self._ai_write_selected_assets(state)
if not selected_assets:
self._message("缺少可用商品原图", "请先添加商品原图,或等待已拉取的商品原图下载完成。")
return
selected_assets = local_assets[: ai.CMHUB_VISION_MAX_IMAGES]
self._save_controls_to_state(state)
selected_asset_ids = tuple(int(asset.id) for asset in selected_assets)
catalog_params = self._ai_write_catalog_params()
cached_models = (
cmhub_models.cached_model_catalog(catalog_params["base_url"], catalog_params["alias"])
if catalog_params is not None
else None
)
if cached_models is not None:
self._confirm_ai_write_request(
state,
selected_asset_ids,
self._ai_write_points_cost(cached_models, catalog_params["alias"]),
)
return
if catalog_params is None:
self._confirm_ai_write_request(state, selected_asset_ids, None)
return
self._start_ai_write_catalog_lookup(state, selected_asset_ids, catalog_params)
def _ai_write_selected_assets(self, state):
return [
asset
for asset in self._original_assets(state)
if _asset_usable(asset)
][: ai.CMHUB_VISION_MAX_IMAGES]
def _ai_write_catalog_params(self):
try:
cmhub_config = appconfig.cmhub_config(self.config)
base_url = appconfig.normalize_cmhub_base_url(cmhub_config.get("base_url"))
alias = str(cmhub_config.get("vision_alias") or "").strip()
api_key = appconfig.get_cmhub_api_key(self.cmhub_config_path)
except Exception:
return None
if not base_url or not alias or not api_key:
return None
return {
"base_url": base_url,
"alias": alias,
"api_key": api_key,
"connect_timeout": cmhub_config.get("connect_timeout", 10),
"use_system_proxy": bool(appconfig.ai_config(self.config).get("use_system_proxy")),
}
@staticmethod
def _ai_write_points_cost(models, alias):
return cmhub_models.unambiguous_points_cost(
models,
alias,
"vision",
requires_image=True,
)
def _start_ai_write_catalog_lookup(self, state, selected_asset_ids, params):
worker = CMHubModelCatalogWorker(
params["base_url"],
params["api_key"],
connect_timeout=params["connect_timeout"],
use_system_proxy=params["use_system_proxy"],
)
state.ai_price_worker = worker
worker.finished.connect(
lambda result, state=state, asset_ids=selected_asset_ids, alias=params["alias"]:
self._on_ai_write_catalog_finished(state, asset_ids, alias, result)
)
worker.cancelled.connect(
lambda result, state=state: self._on_ai_write_catalog_cancelled(state, result)
)
state.ai_price_thread = self._start_thread(worker, "商品套图读取扣点")
if state is self._displayed_state:
self._apply_running_state(state)
self._status("正在读取图片理解预计扣点", "info")
def _on_ai_write_catalog_finished(self, state, selected_asset_ids, alias, result):
state.ai_price_worker = None
state.ai_price_thread = None
if not self._is_open_suite_state(state):
return
if result.get("ok") is False:
self._status("暂时无法取得图片理解预计扣点,实际以网关返回为准", "warning")
self._confirm_ai_write_request(state, selected_asset_ids, None)
elif not result.get("cancelled"):
self._confirm_ai_write_request(
state,
selected_asset_ids,
self._ai_write_points_cost(result.get("models") or [], alias),
)
if state is self._displayed_state:
self._apply_running_state(state)
def _on_ai_write_catalog_cancelled(self, state, result):
state.ai_price_worker = None
state.ai_price_thread = None
if self._is_open_suite_state(state):
self._status("已取消读取图片理解预计扣点", "warning")
if state is self._displayed_state:
self._apply_running_state(state)
def _is_open_suite_state(self, state):
return self._states.get(getattr(state, "key", None)) is state
def _confirm_ai_write_request(self, state, selected_asset_ids, points_cost):
if not self._is_open_suite_state(state) or state.ai_worker is not None:
return
current_asset_ids = tuple(int(asset.id) for asset in self._ai_write_selected_assets(state))
if current_asset_ids != tuple(selected_asset_ids):
self._status("商品原图已变化,请重新点击AI帮写", "warning")
return
message = self._ai_write_confirmation_message(len(current_asset_ids), points_cost)
state.ai_confirmation_open = True
accepted = False
try:
box = QMessageBox(self)
box.setIcon(QMessageBox.Question)
box.setWindowTitle("开始AI帮写")
box.setText(message)
start_button = box.addButton("开始AI帮写", QMessageBox.AcceptRole)
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
box.setDefaultButton(cancel_button)
box.setEscapeButton(cancel_button)
box.exec()
accepted = box.clickedButton() is start_button
finally:
state.ai_confirmation_open = False
if not accepted:
if state is self._displayed_state:
self._apply_running_state(state)
return
self._start_confirmed_ai_write(state, selected_asset_ids)
@staticmethod
def _ai_write_confirmation_message(image_count, points_cost):
lines = [
"本次会理解当前商品前%d张可用商品原图(最多%d张),并生成商品卖点与要求。"
% (int(image_count), ai.CMHUB_VISION_MAX_IMAGES),
]
price_text = cmhub_models.format_points_cost(points_cost)
if price_text:
lines.append("预计扣点:%s 点,实际以网关返回为准。" % price_text)
else:
lines.append("暂时无法取得预计扣点,实际以网关返回为准。")
lines.append("开始后可取消本地等待;请求已提交到网关时,仍可能产生扣点。")
return "\n".join(lines)
def _start_confirmed_ai_write(self, state, selected_asset_ids):
if not self._is_open_suite_state(state) or state.ai_worker is not None:
return
selected_assets = self._ai_write_selected_assets(state)
if tuple(int(asset.id) for asset in selected_assets) != tuple(selected_asset_ids):
self._status("商品原图已变化,请重新点击AI帮写", "warning")
return
context = (
"商品ID:%s;平台:%s;国家地区:%s;输出语言:%s。当前已有要求:%s"
% (
@@ -4114,13 +4276,16 @@ class ProductSuiteTab(QWidget):
if state is self._displayed_state:
self._apply_running_state(state)
message = "AI帮写已开始,可切换到其他套图任务"
if len(local_assets) > ai.CMHUB_VISION_MAX_IMAGES:
if len([asset for asset in self._original_assets(state) if _asset_usable(asset)]) > 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
if state is not None and state.ai_worker is not None:
if state is not None and state.ai_price_worker is not None:
state.ai_price_worker.cancel()
self._status("已请求取消读取图片理解预计扣点", "warning")
elif state is not None and state.ai_worker is not None:
state.ai_worker.cancel()
self._status("已请求取消AI帮写", "warning")
@@ -4946,7 +5111,7 @@ class ProductSuiteTab(QWidget):
"QPushButton:hover { background: #245fce; }"
)
self._refresh_totals(state)
ai_running = state.ai_worker is not None
ai_running = state.ai_worker is not None or state.ai_price_worker is not None
self.ai_write_button.setEnabled(not ai_running and not generation_running)
self.ai_cancel_button.setVisible(ai_running)
self._update_context_actions(state)
@@ -4967,7 +5132,9 @@ class ProductSuiteTab(QWidget):
)
self.progress_bar.setRange(0, max(1, state.total))
self.progress_bar.setValue(min(state.done, max(1, state.total)))
if state.ai_worker is not None and state.ai_started_at is not None:
if state.ai_price_worker is not None:
self.ai_write_button.setText("读取扣点中")
elif state.ai_worker is not None and state.ai_started_at is not None:
ai_elapsed = int(max(0, time.monotonic() - state.ai_started_at))
self.ai_write_button.setText("AI 帮写中(%d秒)" % ai_elapsed)
else:
@@ -5250,7 +5417,13 @@ class ProductSuiteTab(QWidget):
for state in list(self._states.values()):
self._flush_prompt_save(state)
for state in list(self._states.values()) + list(self._retired_states):
for worker in (state.worker, state.pull_worker, state.import_worker, state.ai_worker):
for worker in (
state.worker,
state.pull_worker,
state.import_worker,
state.ai_worker,
state.ai_price_worker,
):
if worker is not None and hasattr(worker, "cancel"):
worker.cancel()
for worker, thread in list(state.downloads.values()):
+42 -1
View File
@@ -12,7 +12,15 @@ try:
except ModuleNotFoundError: # pragma: no cover - GUI import guard
Signal = None
from .. import ai, image_studio, image_studio_export, image_studio_generation, image_studio_images
from .. import (
ai,
appconfig,
cmhub_models,
image_studio,
image_studio_export,
image_studio_generation,
image_studio_images,
)
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
from .widgets import *
@@ -484,6 +492,38 @@ class ProductSuiteAiWriteWorker(BaseWorker):
}
class CMHubModelCatalogWorker(BaseWorker):
"""Fetch a transient cmhub model catalog without blocking a product workflow."""
def __init__(
self,
base_url,
api_key,
*,
connect_timeout=10,
use_system_proxy=False,
):
super().__init__()
self.base_url = appconfig.normalize_cmhub_base_url(base_url)
self.api_key = str(api_key or "")
self.connect_timeout = max(1, int(connect_timeout or 10))
self.use_system_proxy = bool(use_system_proxy)
def execute(self):
if self.should_cancel():
return {"cancelled": True}
models = ai.fetch_cmhub_models(
self.base_url,
self.api_key,
connect_timeout=self.connect_timeout,
use_system_proxy=self.use_system_proxy,
)
if self.should_cancel():
return {"cancelled": True}
cmhub_models.cache_model_catalog(self.base_url, models)
return {"models": models}
class ProductSuiteImportImagesWorker(BaseWorker):
"""Validate and copy local product images outside the GUI thread."""
@@ -2869,6 +2909,7 @@ class CMHubSettingsWorker(BaseWorker):
self.api_key,
connect_timeout=self.connect_timeout,
)
cmhub_models.cache_model_catalog(self.base_url, models)
balance = None
if self.include_balance:
balance = ai.fetch_cmhub_balance(